> ## Documentation Index
> Fetch the complete documentation index at: https://docs.portalhq.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Client Auth

> Sign your end users in with Portal and initialize the SDK from the resulting session, with no Client Session Token from your backend.

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](../../../resources/authentication-and-api-keys).

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.

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

## Prerequisites

* Authentication enabled for your environment, with at least one sign-in method and your **Auth Environment ID** to hand (see [Enable authentication](../../../resources/authentication/enable-authentication))
* The sign-in methods you want configured — [Email magic links](../../../resources/authentication/email-magic-links), [Google OAuth](../../../resources/authentication/google-oauth), or [Apple OAuth](../../../resources/authentication/apple-oauth)
* A redirect URL on your environment's allow list (see [Redirect URLs](../../../resources/authentication/redirect-urls))
* A working Portal integration (see [Getting Started](./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 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()`](#restore-the-session-on-launch) reports
"not signed in" — but you can keep it from happening at all by excluding the
file:

```xml theme={null}
<!-- res/xml/portal_backup_rules.xml — Android 11 and below -->
<full-backup-content>
  <exclude domain="sharedpref" path="PortalAuth.xml" />
</full-backup-content>
```

```xml theme={null}
<!-- res/xml/portal_data_extraction_rules.xml — Android 12 and later -->
<data-extraction-rules>
  <cloud-backup>
    <exclude domain="sharedpref" path="PortalAuth.xml" />
  </cloud-backup>
  <device-transfer>
    <exclude domain="sharedpref" path="PortalAuth.xml" />
  </device-transfer>
</data-extraction-rules>
```

Point your `<application>` tag at both, since which one applies depends on the
OS version the backup is taken on:

```xml theme={null}
<application
    ...
    android:fullBackupContent="@xml/portal_backup_rules"
    android:dataExtractionRules="@xml/portal_data_extraction_rules">
</application>
```

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

## 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:

<Warning>
  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](https://developer.android.com/training/app-links) 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.
</Warning>

```xml theme={null}
<activity
    android:name=".MainActivity"
    android:exported="true"
    android:launchMode="singleTask">

  <!-- your existing MAIN/LAUNCHER intent-filter stays as it is -->

  <intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="myapp" android:host="auth" />
  </intent-filter>
</activity>
```

* `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:

```xml theme={null}
<intent-filter android:autoVerify="true">
  <action android:name="android.intent.action.VIEW" />
  <category android:name="android.intent.category.DEFAULT" />
  <category android:name="android.intent.category.BROWSABLE" />
  <data android:scheme="https" android:host="example.com" android:pathPrefix="/auth/callback" />
</intent-filter>
```

Under `singleTask` the new `Intent` does **not** replace the one
`getIntent()` returns, so override `onNewIntent` and call `setIntent` yourself:

```kotlin theme={null}
override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    setIntent(intent)
    handleIntent(intent)
}
```

<Warning>
  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](../../../resources/authentication/redirect-urls#use-one-string-everywhere).
</Warning>

## Create a PortalAuth

Create one long-lived `PortalAuth` and reuse it.

```kotlin theme={null}
import io.portalhq.android.auth.PortalAuth
import io.portalhq.android.auth.data.MagicLinkConfig

// `by lazy`, not an eager initializer — see the warning below.
val portalAuth: PortalAuth by lazy {
    PortalAuth(
        authEnvironmentId = "YOUR_AUTH_ENVIRONMENT_ID",
        redirectUrl = "myapp://auth/callback",
        // Required only by sendMagicLink()
        magicLink = MagicLinkConfig(
            fromEmail = "hello@auth.example.com",
            templateId = "YOUR_TEMPLATE_ID",
        ),
        // Optional: whether the client Portal creates uses gas sponsorship
        // isAccountAbstracted = true,
    )
}
```

| Option | Type | Required | Description |
| - | - | - | - |
| `authEnvironmentId` | `String` | Yes | Your environment's Auth Environment ID. Also the key the session is stored under. |
| `redirectUrl` | `String` | Yes | Where Portal returns the user. Must be allow-listed. |
| `apiHost` | `String` | No | API host override. Defaults to `api.portalhq.io`. |
| `magicLink` | `MagicLinkConfig` | No | Required by `sendMagicLink()` only. OAuth-only apps can omit it. |
| `isAccountAbstracted` | `Boolean?` | No | Whether the client Portal creates for a first-time user uses gas sponsorship. See [Gas sponsorship](../../../resources/authentication/overview#gas-sponsorship). |

A blank `authEnvironmentId` or `redirectUrl` throws `IllegalArgumentException`.

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

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

<Note>
  `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.
</Note>

## Check which methods are enabled

`getMethods()` reports what your environment allows, so you can render only the
buttons that will work.

```kotlin theme={null}
import io.portalhq.android.auth.data.AuthMethodsResult

private suspend fun loadMethods(): AuthMethodsResult {
    val methods = portalAuth.getMethods()

    Log.i("[PortalAuth]", "✅ enabled: ${methods.allowedAuthMethods}")
    Log.i("[PortalAuth]", "✅ autoCreateWallet: ${methods.autoCreateWallet}")

    return methods
}
```

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](#create-or-reuse-the-wallet).

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

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

```kotlin theme={null}
import android.content.Intent
import android.net.Uri
import io.portalhq.android.auth.data.AuthMethod

private suspend fun signInWithProvider(method: AuthMethod) {
    val (authorizeUrl) = when (method) {
        AuthMethod.GOOGLE -> portalAuth.loginWithGoogle()
        AuthMethod.APPLE -> portalAuth.loginWithApple()
        else -> return
    }

    startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(authorizeUrl)))
}
```

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

<Note>
  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](#complete-the-sign-in).
</Note>

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.

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

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

```kotlin theme={null}
private suspend fun sendMagicLink(input: String) {
    // The API requires a lowercase address and rejects anything else with a 400.
    val email = input.trim().lowercase()

    portalAuth.sendMagicLink(email)

    Log.i("[PortalAuth]", "✅ magic link sent — check $email")
}
```

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

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

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:

```kotlin theme={null}
import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.lifecycle.lifecycleScope
import io.portalhq.android.auth.data.AuthResult
import io.portalhq.android.exceptions.PortalException
import java.io.IOException
import kotlinx.coroutines.launch

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    handleIntent(intent)
}

override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    // `singleTask` does not refresh getIntent() on its own.
    setIntent(intent)
    handleIntent(intent)
}

private fun handleIntent(incoming: Intent?) {
    val intent = incoming ?: return
    val url = intent.data?.toString() ?: return

    lifecycleScope.launch {
        try {
            val result = portalAuth.handleRedirect(url)

            // Not a Client Auth redirect — leave the Intent as it is so the rest
            // of your routing still sees it.
            if (result == null) return@launch

            // Consumed. Clear it so a process recreated with this Intent still
            // attached does not try to exchange the same grant a second time.
            intent.data = null
            setIntent(intent)

            when (result) {
                is AuthResult.TotpRequired -> promptForTotp(result)

                // Must be safe to run twice — see the warning below.
                is AuthResult.Authenticated -> onSignedIn(result.session)
            }
        } catch (e: PortalException.Auth.AuthenticationFailed) {
            Log.e("[PortalAuth]", "❌ the user did not complete the provider sign-in", e)
        } catch (e: IOException) {
            // The grant was not spent — leave the Intent in place so the redirect can be retried.
            Log.e("[PortalAuth]", "❌ network failure exchanging the redirect — retry", e)
        } catch (e: PortalException) {
            Log.e("[PortalAuth]", "❌ handleRedirect failed", e)
        }
    }
}
```

`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()`](#end-the-session), 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.

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

<Warning>
  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()`](#restore-the-session-on-launch) 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.
</Warning>

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()`:

```kotlin theme={null}
import io.portalhq.android.auth.PortalSession
import io.portalhq.android.auth.data.AuthResult
import java.io.IOException

private suspend fun verifyTotp(
    challenge: AuthResult.TotpRequired,
    code: String,
): PortalSession? = try {
    val result = portalAuth.verifyTotp(code, challenge.userJwt)

    // The same session the non-TOTP path returns. Pass it to Portal(credentials = …).
    result.session
} catch (e: IOException) {
    // Transport failure — the code was never checked, so the same code can be retried.
    Log.e("[PortalAuth]", "❌ verifyTotp could not reach Portal — retry", e)
    null
} catch (e: PortalException) {
    // A rejected code does not consume the userJwt, so prompting again is normal.
    Log.e("[PortalAuth]", "❌ verifyTotp failed — ask for the next code", e)
    null
}
```

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

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

<Warning>
  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`.
</Warning>

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](../../../resources/authentication/two-factor-authentication).

## Initialize Portal from the session

Pass the session as `credentials`. Everything else about `Portal` is unchanged,
including how you configure backups.

```kotlin theme={null}
import io.portalhq.android.Portal
import io.portalhq.android.auth.PortalSession

private fun createPortal(session: PortalSession): Portal {
    val portal = Portal(
        credentials = session,
        rpcConfig = mapOf("eip155:11155111" to YOUR_GATEWAY_URL),
    )

    portal.configureGoogleStorage(
        GDriveConfiguration(
            clientId = YOUR_GDRIVE_CLIENT_ID,
            signOutAfterUse = true,
            gDriveBackupOption = GDriveBackupOption.AppDataFolder,
        )
    )

    return portal
}
```

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

<Warning>
  `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.
</Warning>

<Note>
  [`portal.createWebView()`](./build-a-webview) 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.
</Note>

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

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

```kotlin theme={null}
private suspend fun resolveWallet(portal: Portal) {
    if (portal.doesWalletExistOrThrow()) {
        if (portal.isWalletOnDeviceOrThrow()) {
            Log.i("[PortalAuth]", "✅ reusing the existing wallet")
        } else {
            // The wallet exists elsewhere — recover it. See Recover a wallet.
            Log.i("[PortalAuth]", "✅ wallet exists but is not on this device")
        }
        return
    }

    if (!portalAuth.getMethods().autoCreateWallet) {
        Log.i("[PortalAuth]", "✅ autoCreateWallet is off — not creating a wallet")
        return
    }

    portal.createWallet()
        .onSuccess { Log.i("[PortalAuth]", "✅ wallet created: $it") }
        .onFailure { Log.e("[PortalAuth]", "❌ createWallet failed", it) }
}
```

See [Create a wallet](./create-a-wallet) for the full wallet lifecycle, and
[Manage wallet lifecycle states](./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.

```kotlin theme={null}
private suspend fun restore(): PortalSession? = try {
    val session = portalAuth.restoreSession()

    if (session == null) {
        Log.i("[PortalAuth]", "✅ nothing stored — show the sign-in screen")
    }

    session
} catch (e: PortalException.Auth.SessionStorageFailure) {
    // Storage itself failed this time. It may succeed on the next attempt.
    Log.e("[PortalAuth]", "❌ restoreSession failed", e)
    null
}
```

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

<Warning>
  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`.
</Warning>

## End the session

There are three ways a session ends, and they are not interchangeable.

| Operation | What it does | Use it when |
| - | - | - |
| `portal.clearSession()` | Drops the in-memory token **and** deletes the stored copy. | Signing the current user out. |
| `portalAuth.clearPersistedSession()` | Deletes the stored copy only, and forgets the replayable grant. A live `Portal` keeps working. | You have no `Portal` in hand — clearing corrupt storage, or a leftover before a fresh sign-in. |
| `portal.onSessionInvalidated()` | Fires when the backend ends the session. | Reacting to a sign-out you did not initiate. |

```kotlin theme={null}
// Signing the current user out.
private suspend fun signOut(portal: Portal) {
    portal.clearSession()
}

// Clearing storage when there is no Portal to clear through.
private suspend fun clearStoredSession() {
    portalAuth.clearPersistedSession()
}
```

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.

<Warning>
  `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()`.
</Warning>

<Note>
  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](../../../resources/authentication-and-api-keys)
  for session lifetimes.
</Note>

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

## 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:

```kotlin theme={null}
import io.portalhq.android.Portal
import io.portalhq.android.auth.PortalSession
import java.io.Closeable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow

object PortalHolder {
    private val _sessionEnded = MutableStateFlow(false)
    val sessionEnded: StateFlow<Boolean> = _sessionEnded

    private var portal: Portal? = null
    private var subscription: Closeable? = null

    fun adopt(session: PortalSession): Portal = synchronized(this) {
        // Closed first: each Portal needs its own subscription, and the previous
        // one would otherwise stay reachable for the life of its session.
        subscription?.close()

        val created = createPortal(session)
        // Runs on whichever thread saw the 401 — record state, never touch UI.
        subscription = created.onSessionInvalidated { _sessionEnded.value = true }

        portal = created
        created
    }

    fun signedOut(): Unit = synchronized(this) {
        subscription?.close()
        subscription = null
        portal = null
        _sessionEnded.value = false
    }
}
```

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.

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

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

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

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

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

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

## Handle errors

Client Auth surfaces two families of exceptions.

`PortalException.Auth` — a sign-in that could not be completed:

| Exception | What happened | What to do |
| - | - | - |
| `MagicLinkNotConfigured` | `sendMagicLink()` was called with no `magicLink` config. Raised before any network call. | Pass `magicLink` to the `PortalAuth` constructor. |
| `AuthMethodUnavailable` | The provider is not enabled for this environment. | Not retryable. Read `getMethods()` and present the method as unavailable. |
| `AuthenticationFailed` | The redirect carried an `error` parameter — the provider sent the user back without a grant. | The user never finished the sign-in. Offer to start again. |
| `InvalidGrantResponse` | The exchange returned neither a session token nor a `userJwt`. | Retry the sign-in; report it if it persists. |
| `InvalidUserJwt` | The `userJwt` handed to `verifyTotp()` could not be read. Raised before the network call. | Pass `AuthResult.TotpRequired.userJwt` back verbatim. |
| `MalformedResponse` | A response was missing its `{ "data": … }` envelope or a required field. | Retry; report it if it persists. |
| `SessionStorageFailure` | Device storage or the Keystore failed this time. | Depends on where it came from — see below. Distinct from the `null` that means "not signed in". |

<Warning>
  `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.
</Warning>

`PortalException.Credentials.PortalCredentialError` — a credential that could
not be turned into a token. Branch on its `reason`:

| Reason | What happened | What to do |
| - | - | - |
| `SESSION_INVALIDATED` | The session is known to be dead. Raised by the requests the SDK makes itself; a token the MPC binary rejected reports as `MpcResultError` instead and fires `onSessionInvalidated()`. | Re-authenticate. `requiresReauthentication` is `true`. |
| `UNAVAILABLE` | Resolution produced nothing usable. | Check that `Portal` was constructed with `credentials` or `apiKey`. |
| `PROVIDER_FAILURE` | The credential provider itself failed, such as a storage read. | Retry, or clear the stored session and sign in again. |

```kotlin theme={null}
import io.portalhq.android.exceptions.PortalException

/**
 * @param completingSignIn true when the failure came from handleRedirect() or
 *   verifyTotp(). A 400 means a spent or expired grant only there; anywhere else
 *   it is a rejected request and must not be reported as an expiry.
 */
private fun describe(error: Throwable, completingSignIn: Boolean): String = when (error) {
    is PortalException.Auth.AuthMethodUnavailable ->
        "That sign-in method is not available."

    is PortalException.Auth.AuthenticationFailed ->
        "Sign-in was not completed."

    is PortalException.Credentials.PortalCredentialError ->
        if (error.requiresReauthentication) {
            "Your session ended. Please sign in again."
        } else {
            "Could not read your session. Please try again."
        }

    is PortalException.Api.HttpBadRequest ->
        if (completingSignIn) {
            "That sign-in link has expired. Please start a new sign-in."
        } else {
            "That request was rejected. Please check your details and try again."
        }

    else -> "Something went wrong."
}
```

<Note>
  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](./error-handling) for the full hierarchy and the
  [request timeouts](./error-handling#request-timeouts) that bound every call.
</Note>

<Warning>
  `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.
</Warning>

## Next Steps

Now that your user is signed in, [create a wallet](./create-a-wallet) and
[send tokens](./send-tokens).

For the dashboard and provider configuration behind this flow, see
[Authentication](../../../resources/authentication/overview).
