> ## 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().initialize()`. No login system of your own, and no server-side call
to mint a [Client Session Token](../../../resources/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 listens for deep links.

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

`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`](https://pub.dev/packages/url_launcher) and
[`app_links`](https://pub.dev/packages/app_links), plus
[`qr_flutter`](https://pub.dev/packages/qr_flutter) if your environment requires
two-factor authentication. Any equivalent packages work.

```yaml theme={null}
dependencies:
  portal_flutter: ^2.0.0
  app_links: ^7.2.1
  url_launcher: ^6.3.3
  # Only if your environment requires two-factor authentication
  qr_flutter: ^4.1.0
  # Only for a clean slate on iOS reinstalls (see below)
  shared_preferences: ^2.5.5
```

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()`](#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}
<!-- android/app/src/main/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}
<!-- android/app/src/main/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 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:

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

<Tabs>
  <Tab title="Android">
    In `android/app/src/main/AndroidManifest.xml`, add an intent filter and the
    deep-linking flag to the activity that hosts Flutter:

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

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

      <meta-data android:name="flutter_deeplinking_enabled" android:value="false" />

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

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

    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:

    ```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>
    ```
  </Tab>

  <Tab title="iOS">
    In `ios/Runner/Info.plist`, register the scheme and turn off Flutter's deep
    linking:

    ```xml theme={null}
    <key>CFBundleURLTypes</key>
    <array>
      <dict>
        <key>CFBundleURLName</key>
        <string>com.example.myapp.auth</string>
        <key>CFBundleURLSchemes</key>
        <array>
          <string>myapp</string>
        </array>
      </dict>
    </array>
    <key>FlutterDeepLinkingEnabled</key>
    <false/>
    ```

    If `CFBundleURLTypes` already exists — for Google Sign-In, say — add the `dict`
    to the existing array rather than declaring the key twice.

    <Warning>
      A custom scheme is not exclusive on iOS either: another app can register
      `myapp://` and receive the redirect, grant included. For production, prefer a
      [Universal Link](https://developer.apple.com/documentation/xcode/supporting-universal-links-in-your-app)
      on a domain you own, which needs the Associated Domains entitlement and an
      `apple-app-site-association` file on that domain. Pass the HTTPS URL as your
      `redirectUrl`.
    </Warning>
  </Tab>
</Tabs>

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

```dart theme={null}
import 'dart:async';

import 'package:app_links/app_links.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

class DeepLinks {
  static final DeepLinks _instance = DeepLinks._();
  factory DeepLinks() => _instance;
  DeepLinks._();

  final _links = StreamController<Uri>.broadcast();
  StreamSubscription<Uri>? _subscription;
  Uri? _pending;

  /// Every link as it arrives, for a sign-in screen that is already showing.
  Stream<Uri> get links => _links.stream;

  void start() {
    _subscription ??= AppLinks().uriLinkStream.listen(
      (uri) {
        _pending = uri;
        _links.add(uri);
      },
      onError: (Object e) => debugPrint('❌ deep link error: $e'),
    );
  }

  /// Returns the pending link and clears it, so it is handled once.
  Uri? takePending() {
    final uri = _pending;
    _pending = null;
    return uri;
  }
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  DeepLinks().start();
  runApp(const MyApp());
}
```

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

## Configure PortalAuth

`PortalAuth()` always returns the same instance, backed by a single native
`PortalAuth`. Configure it once at startup:

```dart theme={null}
import 'package:portal_flutter/portal_flutter.dart';

Future<void> configureAuth() async {
  await PortalAuth().configure(
    authEnvironmentId: 'YOUR_AUTH_ENVIRONMENT_ID',
    redirectUrl: 'myapp://auth/callback',
    // Required only by sendMagicLink()
    magicLink: PortalMagicLinkConfig(
      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 and registered natively. |
| `apiHost` | `String?` | No | API host override. Defaults to `api.portalhq.io`, and must point at the backend that owns `authEnvironmentId`. |
| `magicLink` | `PortalMagicLinkConfig?` | No | Required by `sendMagicLink()` only. OAuth-only apps can omit it. |
| `isAccountAbstracted` | `bool?` | 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 a `PortalException` with
code `AUTH_INVALID_ARGUMENT`. Calling any other `PortalAuth` method before
`configure()` throws `AUTH_NOT_CONFIGURED`.

<Warning>
  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](#complete-the-sign-in)) — so do
  not call it from a widget's `build` or `initState`.
</Warning>

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

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

```dart theme={null}
import 'package:flutter/foundation.dart';
import 'package:portal_flutter/portal_flutter.dart';

Future<PortalAuthMethods> loadMethods() async {
  final methods = await PortalAuth().getMethods();

  debugPrint('✅ enabled: ${methods.allowedAuthMethods}'); // [PortalAuthMethod.google, PortalAuthMethod.emailMagicLink]
  debugPrint('✅ autoCreateWallet: ${methods.autoCreateWallet}');

  return methods;
}
```

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

```dart theme={null}
import 'package:portal_flutter/portal_flutter.dart';
import 'package:url_launcher/url_launcher.dart';

Future<void> signInWithProvider(PortalAuthMethod method) async {
  final result = method == PortalAuthMethod.apple
      ? await PortalAuth().loginWithApple()
      : await PortalAuth().loginWithGoogle();

  await launchUrl(
    Uri.parse(result.authorizeUrl),
    mode: LaunchMode.externalApplication,
  );
}
```

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

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.

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

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

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

```dart theme={null}
import 'package:flutter/foundation.dart';
import 'package:portal_flutter/portal_flutter.dart';

Future<void> sendMagicLink(String email) async {
  // Trimmed and lowercased by the SDK before it is sent.
  await PortalAuth().sendMagicLink(email);

  debugPrint('✅ magic link sent — check $email');
}
```

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.

<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. Going
  over the limit throws `AUTH_RATE_LIMITED`.
</Note>

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:

```dart theme={null}
import 'package:flutter/foundation.dart';
import 'package:portal_flutter/portal_flutter.dart';

/// Returns whether [uri] was a Client Auth redirect that was handled.
Future<bool> completeSignIn(Uri uri) async {
  try {
    final result = await PortalAuth().handleRedirect(uri.toString());

    switch (result) {
      case null:
        // Not a Client Auth redirect — let the rest of your routing have it.
        return false;
      case PortalTotpRequired():
        promptForTotp(result);
      case PortalAuthenticated():
        // Must be safe to run twice — see the warning below.
        await onSignedIn(result.session);
    }
    return true;
  } on PortalException catch (e) {
    if (e.code == 'AUTH_AUTHENTICATION_FAILED') {
      debugPrint('❌ the user did not complete the provider sign-in');
    } else {
      debugPrint('❌ handleRedirect failed (${e.code}): ${e.message}');
    }
    return false;
  }
}
```

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.

```dart theme={null}
import 'package:flutter/foundation.dart';

Future<void> _queue = Future.value();

void _enqueue(Future<void> Function() step) {
  _queue = _queue
      .then((_) => step())
      .catchError((Object e) => debugPrint('❌ sign-in step failed: $e'));
}

Future<void> startSignInFlow() async {
  await configureAuth();

  final initial = DeepLinks().takePending();
  DeepLinks().links.listen((uri) {
    DeepLinks().takePending();
    _enqueue(() => completeSignIn(uri));
  });

  _enqueue(() async {
    // A pending link is a newer sign-in than anything already persisted.
    if (initial != null && await completeSignIn(initial)) return;

    // No link, or one that failed: a relaunch can re-deliver a link whose
    // session is already in storage.
    final session = await restore();
    if (session != null) await onSignedIn(session);
  });
}
```

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

<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>
  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()`](#restore-the-session-on-launch) before sending the user
  back to the sign-in screen.
</Warning>

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.

| Field | Type | Description |
| - | - | - |
| `userJwt` | `String` | Short-lived credential for the verification step. Pass it back to `verifyTotp()` verbatim. |
| `totpLink` | `String?` | An `otpauth://` enrollment URI on the user's first TOTP sign-in, and `null` once they are enrolled. |
| `totpSecret` | `String?` | The base32 secret inside `totpLink`, for typing into an authenticator app by hand. `null` when there is nothing to enroll. |
| `endUserId` | `String` | The Portal end user signing in. |

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

```dart theme={null}
import 'package:flutter/foundation.dart';
import 'package:portal_flutter/portal_flutter.dart';

Future<PortalSession?> verifyTotp(
  PortalTotpRequired challenge,
  String code,
) async {
  try {
    final result = await PortalAuth().verifyTotp(
      code,
      userJwt: challenge.userJwt,
    );

    // The same session the non-TOTP path returns. Pass it to initialize(session:).
    return result.session;
  } on PortalException catch (e) {
    if (e.code == 'AUTH_TOTP_REJECTED') {
      // A rejected code does not consume the userJwt, so prompting again is normal.
      debugPrint('❌ code rejected — ask for the next code');
    } else {
      debugPrint('❌ verifyTotp failed (${e.code}): ${e.message}');
    }
    return null;
  }
}
```

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.

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

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

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:

```dart theme={null}
import 'package:flutter/material.dart';
import 'package:portal_flutter/portal_flutter.dart';
import 'package:qr_flutter/qr_flutter.dart';

Widget enrollment(PortalTotpRequired challenge) {
  final link = challenge.totpLink;
  final secret = challenge.totpSecret;

  // Already enrolled: just ask for the code.
  if (link == null) return const SizedBox.shrink();

  return Column(
    children: [
      QrImageView(data: link, size: 200),
      if (secret != null) SelectableText(secret),
    ],
  );
}
```

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 to `initialize()` as `session`, in place of `apiKey`.
Everything else about `Portal` is unchanged, including how you configure
backups.

```dart theme={null}
import 'package:portal_flutter/portal_flutter.dart';

Future<void> initializePortal(PortalSession session) async {
  await Portal().initialize(
    session: session,
    rpcConfig: {
      'eip155:11155111': 'https://api.portalhq.io/rpc/v1/eip155/11155111',
    },
  );
}
```

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()`](../reference/getclient).

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

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

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

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

```dart theme={null}
import 'package:flutter/foundation.dart';
import 'package:portal_flutter/portal_flutter.dart';

Future<void> resolveWallet(PortalAuthMethods methods) async {
  final portal = Portal();

  if (await portal.isWalletOnDevice()) {
    debugPrint('✅ reusing the wallet on this device');
    return;
  }

  final client = await portal.getClient();
  if (client.wallets.isNotEmpty) {
    // The wallet exists elsewhere — recover it. See Recover a wallet.
    debugPrint('✅ wallet exists but is not on this device');
    return;
  }

  if (!methods.autoCreateWallet) {
    debugPrint('✅ autoCreateWallet is off — not creating a wallet');
    return;
  }

  final addresses = await portal.createWallet();
  debugPrint('✅ wallet created: ${addresses.ethereum}');
}
```

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](./create-a-wallet) and
[Recover a wallet](./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.

```dart theme={null}
import 'package:flutter/foundation.dart';
import 'package:portal_flutter/portal_flutter.dart';

Future<PortalSession?> restore() async {
  try {
    final session = await PortalAuth().restoreSession();

    if (session == null) {
      debugPrint('✅ nothing stored — show the sign-in screen');
    }

    return session;
  } on PortalException catch (e) {
    // AUTH_SESSION_STORAGE_FAILURE: storage failed this time. It may succeed on the next attempt.
    debugPrint('❌ restoreSession failed (${e.code}): ${e.message}');
    return null;
  }
}
```

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

<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` and fires
  [`onSessionInvalidated`](#handle-session-invalidation).
</Warning>

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

```dart theme={null}
import 'package:flutter/foundation.dart';
import 'package:portal_flutter/portal_flutter.dart';
import 'package:shared_preferences/shared_preferences.dart';

const _clearedKey = 'portalAuth.clearedAfterInstall';

Future<void> clearSessionLeftByPreviousInstall() async {
  if (defaultTargetPlatform != TargetPlatform.iOS) return;

  try {
    final prefs = await SharedPreferences.getInstance();
    if (prefs.getBool(_clearedKey) ?? false) return;

    await PortalAuth().clearPersistedSession();
    await prefs.setBool(_clearedKey, true);
  } catch (e) {
    debugPrint('❌ could not clear the previous install\'s session: $e');
  }
}
```

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.

| Operation | What it does | Use it when |
| - | - | - |
| `Portal().clearSession()` | Drops the in-memory token **and** deletes the stored copy, and returns `Portal` to uninitialized. | Signing the current user out. |
| `PortalAuth().clearPersistedSession()` | Deletes the stored copy only, and forgets the grant `handleRedirect()` last exchanged. An initialized `Portal` keeps working. | Alongside `clearSession()` when signing out, and whenever you have no initialized `Portal`. |
| `Portal().onSessionInvalidated` | Emits when the backend ends the session. | Reacting to a sign-out you did not initiate. |

```dart theme={null}
import 'package:flutter/foundation.dart';
import 'package:portal_flutter/portal_flutter.dart';

Future<void> signOut() async {
  try {
    await Portal().clearSession();
  } on PortalException catch (e) {
    debugPrint('❌ clearSession failed (${e.code}): ${e.message}');
  }

  // Runs even when clearSession() failed, so the next launch cannot restore
  // the session the user just signed out of.
  await PortalAuth().clearPersistedSession();
}
```

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.

<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
  [API Keys](../../../resources/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 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:

```dart theme={null}
import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:portal_flutter/portal_flutter.dart';

class SessionState {
  static final SessionState _instance = SessionState._();
  factory SessionState() => _instance;
  SessionState._();

  final sessionEnded = ValueNotifier<bool>(false);
  StreamSubscription<void>? _subscription;

  /// Call once at startup, before the first initialize(session:).
  void start() {
    _subscription ??= Portal().onSessionInvalidated.listen((_) {
      sessionEnded.value = true;
    });
  }

  /// Call after initialize(session:) with a new session.
  void signedIn() => sessionEnded.value = false;
}
```

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.

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

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](./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.

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

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

Every failure is a `PortalException`. Branch on its `code`.

`PortalAuth` calls throw `AUTH_*` codes:

| Code | What happened | What to do |
| - | - | - |
| `AUTH_NOT_CONFIGURED` | A `PortalAuth` method ran before `configure()`. | Call `configure()` first. |
| `AUTH_INVALID_ARGUMENT` | A required value was blank, or a TOTP code was not at least six digits. Raised before any network call. | Fix the input. |
| `AUTH_MAGIC_LINK_NOT_CONFIGURED` | `sendMagicLink()` ran with no `magicLink` config. Raised before any network call. | Pass `magicLink` to `configure()`. |
| `AUTH_METHOD_UNAVAILABLE` | The provider is not enabled for this environment. | Not retryable. Read `getMethods()` and present the method as unavailable. |
| `AUTH_RATE_LIMITED` | Too many magic links sent to the address. | Wait, then let the user resend. |
| `AUTH_AUTHENTICATION_FAILED` | 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. |
| `AUTH_TOTP_REJECTED` | `verifyTotp()` got a wrong code or an expired `userJwt`. | Prompt for the next code; restart the sign-in if it keeps failing. |
| `AUTH_INVALID_USER_JWT` | The `userJwt` passed to `verifyTotp()` could not be read. Raised before the network call. | Pass `PortalTotpRequired.userJwt` back verbatim. |
| `AUTH_INVALID_GRANT_RESPONSE` | The exchange returned neither a session token nor a `userJwt`. | Retry the sign-in; report it if it persists. |
| `AUTH_MALFORMED_RESPONSE` | A response was missing a required field. | Retry; report it if it persists. |
| `AUTH_ACCOUNT_ABSTRACTION_UNAVAILABLE` | `isAccountAbstracted: true` was rejected because gas sponsorship is not enabled or configured. iOS only — Android reports it under the call's own code. | Enable gas sponsorship, or stop passing `isAccountAbstracted: true`. |
| `AUTH_SESSION_STORAGE_FAILURE` | Device secure storage failed this time. | Depends on where it came from — see below. Distinct from the `null` that means "not signed in". |
| `AUTH_CONFIGURE_ERROR`, `AUTH_GET_METHODS_ERROR`, `AUTH_SEND_MAGIC_LINK_ERROR`, `AUTH_LOGIN_WITH_GOOGLE_ERROR`, `AUTH_LOGIN_WITH_APPLE_ERROR`, `AUTH_HANDLE_REDIRECT_ERROR`, `AUTH_VERIFY_TOTP_ERROR`, `AUTH_RESTORE_SESSION_ERROR`, `AUTH_CLEAR_PERSISTED_SESSION_ERROR` | Anything else, named after the call: a network failure, a server error, or — from `handleRedirect()` — a grant that was already used, expired, or issued for another environment. | Read `message` for what actually failed. From `handleRedirect()`, start a new sign-in. |

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

`Portal` calls made with a session that cannot be used throw one of these,
whichever method you called:

| Code | What happened | What to do |
| - | - | - |
| `SESSION_INVALIDATED` | The session is known to be dead. | Sign the user in again. `requiresReauthentication` is `true`. |
| `CREDENTIAL_UNAVAILABLE` | Resolving the credential produced nothing usable. | Check that `Portal` was initialized with a `session` or an `apiKey`. |
| `CREDENTIAL_PROVIDER_FAILURE` | The credential provider itself failed, such as a storage read. | Retry, or clear the stored session and sign in again. |

`PortalException.requiresReauthentication` is `true` exactly when the code is
`SESSION_INVALIDATED`, so you can check it after any call:

```dart theme={null}
import 'package:portal_flutter/portal_flutter.dart';

/// [completingSignIn] is true when the failure came from handleRedirect() or
/// verifyTotp(), where a rejected grant means the sign-in has to start over.
String describe(PortalException e, {required bool completingSignIn}) {
  if (e.requiresReauthentication) {
    return 'Your session ended. Please sign in again.';
  }

  return switch (e.code) {
    'AUTH_METHOD_UNAVAILABLE' => 'That sign-in method is not available.',
    'AUTH_AUTHENTICATION_FAILED' => 'Sign-in was not completed.',
    'AUTH_RATE_LIMITED' => 'Too many emails sent. Please wait a minute.',
    'AUTH_TOTP_REJECTED' => 'That code was not accepted. Try the next one.',
    'AUTH_HANDLE_REDIRECT_ERROR' when completingSignIn =>
      'That sign-in link could not be used. Please start a new sign-in.',
    'CREDENTIAL_UNAVAILABLE' ||
    'CREDENTIAL_PROVIDER_FAILURE' => 'Could not read your session. Please try again.',
    _ => 'Something went wrong.',
  };
}
```

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

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


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.