> ## 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 starts a sign-in, Portal completes it, and `PortalAuth` hands you
a `PortalSession` that you pass straight to `Portal`. No login system of your
own, and no server-side call to mint a
[Client Session Token](/resources/api-keys).

The iOS SDK can complete a Google or Apple sign-in in two ways:

* **`signInWithGoogle()` / `signInWithApple()`** present the provider in an
  `ASWebAuthenticationSession` sheet and return the result directly. Your app
  never opens a URL or handles a redirect. This is the recommended path.
* **`loginWithGoogle()` / `loginWithApple()`** return an authorize URL for your
  app to open however it likes. The result arrives later as a deep link, which
  you pass to `handleRedirect(_:)`. Email magic links always complete this way.

Your app owns **when** a sign-in starts, all of its UI, and forwarding
redirects to the SDK. `PortalAuth` never touches wallets, MPC or signing, and
never creates a `Portal`.

<Note>
  Client Auth requires PortalSwift `8.0.0` or later.
</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 `PortalSwift` alongside
`Portal`, and it adds no dependency of its own: the sign-in sheet is
`AuthenticationServices` and the session lives in the Keychain.

Sessions are persisted for you in the Keychain, under the service
`PortalSwift.auth.session.<authEnvironmentId>`. That is deliberately a separate
namespace from the one holding MPC key shares, so ending a session can never
touch a wallet. The item is stored with
`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` and is never synchronizable,
so it stays on this device and does not sync through iCloud Keychain.

<Note>
  Keychain items survive app deletion, so a reinstalled app can find the previous
  install's session. See [Restore the session on launch](#restore-the-session-on-launch)
  for how to start a fresh install from a clean slate.
</Note>

## Register your redirect

iOS only delivers a redirect to your app if you register its scheme. For a
redirect URL of `myapp://auth/callback`, add the scheme to `Info.plist`:

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

`signInWithGoogle()` and `signInWithApple()` need this custom scheme as well:
the sign-in sheet matches the callback against it. They do not accept an
`https://` redirect URL in this release.

<Warning>
  The redirect carries a single-use grant that `handleRedirect(_:)` exchanges for
  the user's session. A custom scheme is not exclusive on iOS — any installed app
  can register the same `myapp://` scheme, and iOS does not arbitrate between
  them — so a redirect delivered through the OS URL handler can reach another app
  instead of yours. Portal limits the damage with a grant that expires after 15
  minutes and a byte-exact allow list, but it cannot stop another app from
  receiving the URL.

  * `signInWithGoogle()` and `signInWithApple()` are not exposed: the sign-in
    sheet receives the callback itself and never routes it through the OS.
  * Magic links and `loginWithGoogle()` / `loginWithApple()` always arrive through
    the OS. For production, prefer a
    [Universal Link](https://developer.apple.com/documentation/xcode/supporting-universal-links-in-your-app)
    on a domain you own (`https://example.com/auth/callback`), which iOS delivers
    only to the app your domain's `apple-app-site-association` file names.
</Warning>

`redirectUrl` is fixed per `PortalAuth` instance. An app that wants both — a
custom scheme for `signInWith*` and a Universal Link for magic links — builds
two instances with the same Auth Environment ID, one per redirect URL, and
allow-lists both. They share the persisted session, and each one ignores the
other's redirects. Each instance remembers its own last grant, so on sign-out
call `clearPersistedSession()` on both.

<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 (or Universal Link domain) your app registers. 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.

```swift theme={null}
import PortalSwift

let portalAuth = try 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: nil
)
```

| 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. `signInWith*` requires a custom scheme. |
| `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` | `Bool?` | No | Whether the client Portal creates for a first-time user uses gas sponsorship. `nil` leaves it out of every request. See [Gas sponsorship](../../../resources/authentication/overview#gas-sponsorship). |

A blank `authEnvironmentId` or `redirectUrl` throws
`PortalAuthError.invalidArgument(name:)`. The initializer makes no network or
Keychain call.

<Warning>
  Build `PortalAuth` once and hold it for the life of your app — on your app
  delegate, in an object your SwiftUI `App` owns, or in a dependency container —
  rather than one per screen. `handleRedirect(_:)` remembers the grant it last exchanged so a
  re-delivered redirect replays instead of failing, and that memory lives on the
  instance. Sessions are keyed by `authEnvironmentId`, so two instances on the
  same environment share the persisted session anyway.
</Warning>

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

```swift theme={null}
import PortalSwift

func loadMethods() async throws -> AuthMethodsResult {
    let methods = try await portalAuth.getMethods()

    print("✅ enabled: \(methods.allowedAuthMethods.map(\.rawValue))") // ["GOOGLE", "EMAIL_MAGIC_LINK"]
    print("✅ 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.

`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 every completion path must be ready for it.
</Note>

## Start a sign-in

### Google or Apple in a sign-in sheet

`signInWithGoogle()` and `signInWithApple()` fetch a fresh authorize URL,
present the provider in an `ASWebAuthenticationSession` sheet, and exchange the
result, all in one call. Set the window to present from first.

```swift theme={null}
import PortalSwift
import UIKit

@MainActor
func signIn(with method: AuthMethod, from window: UIWindow) async {
    // Held weakly, so set it again if the window changes.
    portalAuth.setAuthPresentationAnchor(window)

    do {
        let result: AuthResult
        switch method {
        case .google:
            result = try await portalAuth.signInWithGoogle()
        case .apple:
            result = try await portalAuth.signInWithApple()
        case .emailMagicLink:
            return
        }

        switch result {
        case .authenticated(let authenticated):
            onSignedIn(authenticated.session)
        case .totpRequired(let step):
            promptForTotp(step)
        }
    } catch PortalAuthSignInError.closed {
        // The user dismissed the sheet. Nothing to clean up.
    } catch PortalAuthSignInError.signInInProgress {
        // A sign-in is already running. Ignore the extra tap.
    } catch {
        print("❌ Sign-in failed: \(error)")
    }
}
```

`signInWith*` throws `PortalAuthSignInError.unavailable` when no presentation
anchor is set (or its window has gone away), or when `redirectUrl` is not a
custom scheme.

Only one `signInWith*` call runs at a time. Both provider URLs share one
single-use `state`, so a second attempt while the first is open throws
`signInInProgress` rather than invalidating the first. Cancelling the calling
`Task` dismisses the sheet and throws `closed`; the grant is never exchanged.

<Note>
  By default the sheet shares Safari's cookies, so a returning user who is already
  signed in to Google or Apple needs one tap. The SDK cannot clear those cookies,
  so after a sign-out the next sign-in reuses the same account. To let the user
  pick a different account, set `portalAuth.prefersEphemeralWebBrowserSession = true`
  before calling `signInWith*`.
</Note>

<Note>
  Portal delivers Sign in with Apple as a web flow, so the button that starts it
  is your own control: follow Apple's Human Interface Guidelines for it. A user
  who picks **Hide My Email** becomes a distinct end user, with a distinct wallet,
  from the same person signing in with Google or a magic link. See
  [How Apple handles email addresses](../../../resources/authentication/apple-oauth#how-apple-handles-email-addresses).
</Note>

### Google or Apple in your own browser

`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. Use this path when you want
to own the browser, or when your redirect URL is a Universal Link.

```swift theme={null}
import PortalSwift
import UIKit

@MainActor
func openSignIn(with method: AuthMethod) async {
    do {
        let authorize: AuthorizeUrlResult
        switch method {
        case .google:
            authorize = try await portalAuth.loginWithGoogle()
        case .apple:
            authorize = try await portalAuth.loginWithApple()
        case .emailMagicLink:
            return
        }

        guard let url = URL(string: authorize.authorizeUrl),
              await UIApplication.shared.open(url)
        else {
            print("❌ Could not open the authorize URL.")
            return
        }
        // The result arrives as a redirect. See Complete the sign-in.
    } catch {
        print("❌ Could not start the sign-in: \(error)")
    }
}
```

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

If the user cancels at the provider, or the provider rejects the sign-in, Portal
redirects to your `redirectUrl` with an `error` parameter, which
`handleRedirect(_:)` throws as `PortalAuthError.authenticationFailed(error:)`.

If you drive your own `ASWebAuthenticationSession` rather than the system
browser, its completion handler receives the callback URL instead of your app's
URL handler. Pass that URL to `handleRedirect(_:)` yourself — or use
`signInWith*`, which does exactly that.

If the provider is not enabled for your environment, both paths throw
`PortalAuthError.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 `PortalRequestsError.unauthorized`,
  which the backend also returns for an invalid Auth Environment ID and for a
  `redirectUrl` that is not on the allow list. 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.

```swift theme={null}
import PortalSwift

@MainActor
func sendMagicLink(to input: String) async {
    do {
        // Trimmed and lowercased by the SDK before it is sent.
        try await portalAuth.sendMagicLink(input)

        print("✅ magic link sent")
    } catch PortalAuthError.rateLimited {
        print("❌ Too many links were sent to this address. Wait a minute and try again.")
    } catch {
        print("❌ sendMagicLink failed: \(error)")
    }
}
```

The API only accepts a lowercase address, so the iOS SDK trims and lowercases
it for you. A blank address throws `PortalAuthError.invalidArgument(name:)`,
and a `PortalAuth` built without `magicLink` throws
`PortalAuthError.magicLinkNotConfigured`, both before any network call.

<Note>
  Every call sends a real email, and sends are rate limited to 10 per address per
  minute, which surfaces as `PortalAuthError.rateLimited`. Never retry
  automatically; make a resend an explicit user action.
</Note>

<Warning>
  `PortalRequestsError.unauthorized` from `sendMagicLink(_:)` is a configuration
  problem, not a user one: the `redirectUrl` is not on the allow list, the
  `fromEmail` does not belong to a sending domain enabled for this environment, or
  the `templateId` does not exist. See
  [Email magic links](../../../resources/authentication/email-magic-links).
</Warning>

The link expires 15 minutes after it is sent. Some email security scanners open
links before the user does, which spends the grant; a `PortalRequestsError.unauthorized`
from `handleRedirect(_:)` is your cue to offer a new link.

## Complete the sign-in

`handleRedirect(_:)` completes every flow that returns through your redirect
URL: magic links and `loginWithGoogle()` / `loginWithApple()`. (`signInWith*`
calls it for you.) Give it the incoming URL and it returns an `AuthResult`,
returns `nil` because the URL was not a Client Auth redirect, or throws.

Forward every URL your app receives to the same call:

<Tabs>
  <Tab title="SwiftUI">
    ```swift theme={null}
    import SwiftUI

    @main
    struct MyApp: App {
        var body: some Scene {
            WindowGroup {
                ContentView()
                    // Custom-scheme URLs and Universal Links, on cold and warm starts.
                    .onOpenURL { url in
                        Task { await completeSignIn(with: url) }
                    }
            }
        }
    }
    ```
  </Tab>

  <Tab title="UIKit (scene delegate)">
    ```swift theme={null}
    import UIKit

    class SceneDelegate: UIResponder, UIWindowSceneDelegate {
        var window: UIWindow?

        // Cold start: the redirect may be what launched the app.
        func scene(
            _ scene: UIScene,
            willConnectTo session: UISceneSession,
            options connectionOptions: UIScene.ConnectionOptions
        ) {
            connectionOptions.urlContexts.forEach { forward($0.url) }
            connectionOptions.userActivities.forEach { forward(universalLink: $0) }
        }

        // Custom-scheme URLs while the app is running.
        func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
            URLContexts.forEach { forward($0.url) }
        }

        // Universal Links while the app is running.
        func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
            forward(universalLink: userActivity)
        }

        private func forward(universalLink activity: NSUserActivity) {
            guard activity.activityType == NSUserActivityTypeBrowsingWeb,
                  let url = activity.webpageURL
            else { return }
            forward(url)
        }

        private func forward(_ url: URL) {
            Task { await completeSignIn(with: url) }
        }
    }
    ```
  </Tab>

  <Tab title="UIKit (app delegate)">
    ```swift theme={null}
    import UIKit

    class AppDelegate: UIResponder, UIApplicationDelegate {
        // Custom-scheme URLs, including the one that launched the app.
        func application(
            _ app: UIApplication,
            open url: URL,
            options: [UIApplication.OpenURLOptionsKey: Any] = [:]
        ) -> Bool {
            Task { await completeSignIn(with: url) }
            return true
        }

        // Universal Links.
        func application(
            _ application: UIApplication,
            continue userActivity: NSUserActivity,
            restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
        ) -> Bool {
            guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
                  let url = userActivity.webpageURL
            else { return false }

            Task { await completeSignIn(with: url) }
            return true
        }
    }
    ```

    In an app without a scene delegate, iOS calls `application(_:open:options:)`
    for a cold start too, once `application(_:didFinishLaunchingWithOptions:)`
    has returned `true`.
  </Tab>
</Tabs>

```swift theme={null}
import Foundation
import PortalSwift

@MainActor
func completeSignIn(with url: URL) async {
    do {
        // Not a Client Auth redirect — let the rest of your routing handle it.
        guard let result = try await portalAuth.handleRedirect(url) else { return }

        switch result {
        case .authenticated(let authenticated):
            // Must be safe to run twice — see the warning below.
            onSignedIn(authenticated.session)
        case .totpRequired(let step):
            promptForTotp(step)
        }
    } catch PortalAuthError.authenticationFailed {
        print("❌ the user did not complete the provider sign-in")
    } catch PortalRequestsError.unauthorized {
        // Already used, expired, or issued for another environment.
        print("❌ this sign-in link is no longer valid — start a new sign-in")
    } catch {
        print("❌ handleRedirect failed: \(error)")
    }
}
```

`handleRedirect(_:)` returns `nil` 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.
It matches the scheme and host case-insensitively and the path exactly, ignoring
the query, the fragment and trailing slashes.

Re-delivery is handled for you. The grant that was last exchanged, and the
`AuthResult` it produced, are remembered on the instance, so a redirect iOS
delivers twice resolves to the same result — and the same `PortalSession`
instance — rather than the backend's single-use rejection. Only exchanges the
backend accepted are remembered, so a redirect that failed on a dropped
connection can be passed in again. If the backend had already spent the grant
before the connection dropped, that retry throws
`PortalRequestsError.unauthorized` and the user starts a new sign-in. The replay
stops once the remembered session is no longer live, after a sign-out or a
`401`: a redirect delivered again past that point throws
`PortalRequestsError.unauthorized`.

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

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. The memory lives on the instance and in this process, so after the
app is terminated, [`restoreSession()`](#restore-the-session-on-launch) is how a
completed sign-in comes back.

On success the session is persisted **before** it is returned, so a Keychain
write failure fails the sign-in with `PortalAuthError.sessionStorageFailure`
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
`.totpRequired(TotpRequiredResult)` instead of a session. Nothing is persisted
yet.

`totpLink` is an `otpauth://` URI on a user's first sign-in, when they still
have to enroll an authenticator app, and `nil` once they are enrolled. Unlike
the other Portal SDKs, the iOS SDK renders the enrollment QR code for you:

```swift theme={null}
import PortalSwift
import UIKit

/// What to show a first-time user. `nil` once the user is enrolled.
func enrollment(for step: TotpRequiredResult) -> (qrCode: UIImage?, manualKey: String?)? {
    guard step.totpLink != nil else { return nil }

    // A QR code for the authenticator app to scan, and the base32 key for users who cannot scan.
    return (try? step.qrCodeImage(), step.totpSecret)
}
```

On success, `verifyTotp(_:userJwt:)` returns the same `AuthenticatedResult` the
non-TOTP path produces, so `session` is the `PortalSession` you carry on with —
hand it to the same place you would hand a session from `handleRedirect(_:)`:

```swift theme={null}
import PortalSwift

@MainActor
func verifyTotp(code: String, step: TotpRequiredResult) async {
    do {
        let authenticated = try await portalAuth.verifyTotp(code, userJwt: step.userJwt)

        // The same session the non-TOTP path returns.
        onSignedIn(authenticated.session)
    } catch PortalRequestsError.unauthorized {
        // A rejected code does not consume the userJwt, so prompting again is normal.
        print("❌ verifyTotp failed — ask for the next code")
    } catch PortalAuthError.invalidUserJwt {
        print("❌ this two-factor step can no longer be used — start a new sign-in")
    } catch {
        // A Keychain write that failed after the code was accepted: retry with the
        // same userJwt. A dropped connection: retrying usually works, but if the code
        // was accepted before the connection dropped, start a new sign-in.
        print("❌ verifyTotp failed: \(error)")
    }
}
```

`verifyTotp(_:userJwt:)` posts the code exactly as given, so check that it is
six digits before you submit it. The `userJwt` expires ten minutes after the
sign-in reached this step; if it expires, or the app is terminated first, the
user starts the sign-in again. Once the code is accepted, a redirect for the
same grant delivered again replays as the session rather than sending the user
back to the prompt.

<Warning>
  A rejected code, an expired `userJwt`, and one that has already been used all
  surface as `PortalRequestsError.unauthorized`, 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>

<Warning>
  `totpLink` and `totpSecret` carry the user's TOTP secret. Never log, persist, or
  send them to analytics. If you offer a "Copy key" button, write to the
  pasteboard with `UIPasteboard.general.setItems(_:options:)`, using
  `.localOnly: true` and a short `.expirationDate`, so the secret stays off
  Universal Clipboard and does not linger.
</Warning>

A `userJwt` the SDK cannot read throws `PortalAuthError.invalidUserJwt(detail:)`
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 register backup methods.

```swift theme={null}
import PortalSwift

func createPortal(session: PortalSession) throws -> Portal {
    try Portal(
        credentials: session,
        withRpcConfig: [
            "eip155:1": "https://api.portalhq.io/rpc/v1/eip155/1",
            "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": "https://api.mainnet-beta.solana.com"
        ]
    )
}
```

`portal.credentials` is the credential the instance authenticates with — the
`PortalSession` here, or a `StaticCredentials` wrapping the key for a `Portal`
built from a Client API Key. Read the current token with
`try portal.credentials.getToken()`. `session.endUserId` is never secret, so it
is the identifier to log and to key your own per-user state on.

`onSignedIn(_:)` in the examples above is where you do this, for example with
`PortalHolder.adopt(_:)` from
[Handle session invalidation](#handle-session-invalidation), which builds the
`Portal` and subscribes to invalidation in one step.

<Warning>
  `portal.apiKey` is an empty string on a session-backed instance and is
  deprecated. Do not read it or forward it anywhere: it carries no credential.
  Use `portal.credentials` instead.
</Warning>

<Note>
  The session token is attached only to RPC URLs that are Portal's own:
  `portalhq.io` or `portalhq.dev` and their subdomains over HTTPS, or a host and
  port this `Portal` was configured with (`apiHost`, `mpcHost`,
  `enclaveMPCHost`). Any other `withRpcConfig` entry, including your own gateway,
  is called without it, so an end user's session token is never handed to a third
  party. `localhost` and `127.0.0.1` qualify only when their host **and port**
  match the `apiHost` you configured, so a local Anvil or Hardhat node never
  receives it.
</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`.

```swift theme={null}
import PortalSwift

func resolveWallet(portal: Portal) async throws {
    if try await portal.doesWalletExist() {
        if try await portal.isWalletOnDevice() {
            print("✅ reusing the existing wallet")
        } else {
            // The wallet exists elsewhere — recover it. See Recover a wallet.
            print("✅ wallet exists but is not on this device")
        }
        return
    }

    guard try await portalAuth.getMethods().autoCreateWallet else {
        print("✅ autoCreateWallet is off — not creating a wallet")
        return
    }

    let addresses = try await portal.createWallet()
    print("✅ 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) 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 the Keychain, without a network
call, so a returning user does not sign in again.

```swift theme={null}
import Foundation
import PortalSwift

func restore() async -> PortalSession? {
    // Keychain items survive app deletion. Start a fresh install from a clean slate.
    let defaults = UserDefaults.standard
    if !defaults.bool(forKey: "portalAuthLaunchedBefore") {
        do {
            try await portalAuth.clearPersistedSession()
            defaults.set(true, forKey: "portalAuthLaunchedBefore")
        } catch {
            // Do not restore: what is stored may belong to the previous install.
            print("❌ could not clear the previous install's session: \(error)")
            return nil
        }
    }

    do {
        guard let session = try await portalAuth.restoreSession() else {
            print("✅ nothing stored — show the sign-in screen")
            return nil
        }
        return session
    } catch {
        // PortalAuthError.sessionStorageFailure: storage failed this time. It may succeed on the next attempt.
        print("❌ restoreSession failed: \(error)")
        return nil
    }
}
```

`nil` means "not signed in", and that includes an entry the SDK can see but
cannot parse. No caller action makes that readable, so the SDK clears it and
reports `nil` rather than failing your launch.
`PortalAuthError.sessionStorageFailure` is reserved for the Keychain 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 grant `handleRedirect(_:)` last exchanged. A live `Portal` keeps working. | Alongside `clearSession()` when signing out, and whenever you have no `Portal` in hand. |
| `portal.onSessionInvalidated(_:)` | Fires when the backend ends the session. | Reacting to a sign-out you did not initiate. |

```swift theme={null}
import PortalSwift

// Signing the current user out. Run both, even if one fails: each clears
// something the other does not.
func signOut(portal: Portal) async {
    do {
        try await portal.clearSession()
    } catch {
        // The in-memory token is gone either way; a stale copy may remain in the Keychain.
        print("❌ clearSession failed: \(error)")
    }

    do {
        try await portalAuth.clearPersistedSession()
    } catch {
        print("❌ clearPersistedSession failed: \(error)")
    }
}
```

Call both on sign-out, each in its own `do`, so a failure in one does not skip
the other. `clearSession()` ends the credential your `Portal` holds, but it does
not reach `PortalAuth`'s memory of the last grant it exchanged. That memory can
hold a different `PortalSession` object — from an earlier redirect in this
process, when your `Portal` was built from `restoreSession()` — and a redirect
delivered again would replay it.

After `clearSession()` this `Portal` is spent: every authenticated call fails
with `PortalCredentialError.sessionInvalidated`. 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
`PortalAuthError.sessionStorageFailure` if the delete fails; for
`clearSession()` the in-memory token is dropped either way, and calling it again
retries the delete.

<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: every
  authenticated request extends it to 24 hours later, until seven days after it
  was issued. See [API Keys](/resources/api-keys#client-session-tokens) for
  session lifetimes.
</Note>

<Note>
  Neither operation deletes the wallet's signing shares — that is what the
  separate Keychain 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 observe:

```swift theme={null}
import Combine
import PortalSwift

@MainActor
final class PortalHolder: ObservableObject {
    static let shared = PortalHolder()

    @Published private(set) var portal: Portal?
    @Published private(set) var sessionEnded = false

    private var subscription: PortalSessionInvalidationHandle?

    private init() {}

    func adopt(_ session: PortalSession) throws {
        // Cancelled first: each Portal needs its own subscription, and a delivery the
        // previous one already queued must not reach the new user.
        subscription?.cancel()

        let created = try createPortal(session: session)
        // Runs once, on the main actor. Capture self weakly: the listener is retained
        // for as long as the session is.
        subscription = created.onSessionInvalidated { [weak self] in
            self?.sessionEnded = true
        }

        portal = created
        sessionEnded = false
    }

    func signedOut() {
        subscription?.cancel()
        subscription = nil
        portal = nil
        sessionEnded = false
    }
}
```

When `sessionEnded` turns `true`, route to sign-in. The SDK has already
invalidated the credential and deleted its stored copy, so do not call
`portal.clearSession()` again; call `portalAuth.clearPersistedSession()` so the
replay memory cannot serve the dead session back, then `signedOut()`.

The callback fires at most once for a credential, 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; that subscription returns
`PortalSessionInvalidationHandle.spent`.

A rejection that happened before you subscribed is not lost. `Portal` starts an
authenticated request as soon as it is constructed, and if that is the request
the backend rejects, a listener you add afterwards still runs, once, on the main
actor. Do not subscribe again from inside the listener: the instance is spent,
and a new subscription would be called as well.

`cancel()` on the returned handle removes the listener, suppresses a delivery
that is already queued but has not run, and is safe to call more than once or
from inside the listener. The handle is **not** cancelled when it is
deallocated, so keep it if you intend to unsubscribe.

What triggers it is a `401` on any request that carried the Portal session
token to a Portal-owned host — Portal's own domains, or a host you configured
on `Portal`, `PortalConnect` or `PortalAuth` — and an MPC `AUTH_FAILED` from a
wallet or signing operation. What cannot trigger it is a request the SDK never
authenticated with the session: a third-party RPC gateway, Google Drive, or a
passkey the user failed or cancelled.

<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. The
  request that saw the `401` fails with its own error, such as
  `PortalRequestsError.unauthorized`, and every later call on that `Portal` throws
  `PortalCredentialError.sessionInvalidated`.
</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 three error types, plus the transport's own errors.

`PortalAuthError` — a sign-in that could not be completed:

| Case | What happened | What to do |
| - | - | - |
| `invalidArgument(name:)` | A required value was blank: `authEnvironmentId` or `redirectUrl` at init, or the address passed to `sendMagicLink(_:)`. Raised before any network call. | Fix the value you pass. |
| `magicLinkNotConfigured` | `sendMagicLink(_:)` was called on an instance built without `magicLink`. Raised before any network call. | Pass `magicLink` to the `PortalAuth` initializer. |
| `authMethodUnavailable(_:)` | The provider is not enabled for this environment. | Not retryable. Read `getMethods()` and present the method as unavailable. |
| `authenticationFailed(error:)` | 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. |
| `rateLimited` | The address hit the magic-link send limit. | Wait before offering a resend. The SDK never retries for you. |
| `accountAbstractionUnavailable(message:)` | `isAccountAbstracted: true` was requested, but gas sponsorship is not enabled or configured for this environment. | Enable it, or stop requesting it. |
| `invalidGrantResponse` | The exchange returned neither a session token nor a `userJwt`. | Retry the sign-in; report it if it persists. |
| `invalidUserJwt(detail:)` | The `userJwt` handed to `verifyTotp(_:userJwt:)` could not be read. Raised before the network call. | Pass `TotpRequiredResult.userJwt` back verbatim, or start a new sign-in. |
| `malformedResponse(path:missing:)` | A response was missing its `{ "data": … }` envelope or a required field. | Retry; report it if it persists. |
| `sessionStorageFailure(message:)` | The Keychain failed this time. | Depends on where it came from — see below. Distinct from the `nil` that means "not signed in". |
| `totpQrUnavailable` | `qrCodeImage(scale:)` could not render `totpLink`. | Show `totpSecret` for manual entry. |

<Warning>
  How you recover from `sessionStorageFailure` depends on where it came from:

  * **`restoreSession()`, `clearPersistedSession()`, `clearSession()`**: storage
    failed this time and may work on the next attempt, so retrying is right.
  * **`verifyTotp(_:userJwt:)`**: the code was accepted and only the Keychain
    write failed. Call `verifyTotp` again with the same `userJwt` — the SDK
    finishes the write and returns the session without another network call.
  * **`handleRedirect(_:)`**: the grant is already spent and only the write
    failed. Passing the same URL to the same `PortalAuth` again finishes the write
    instead of re-sending the grant. From `signInWith*`, which never hands you the
    callback URL, start a new sign-in.
</Warning>

`PortalAuthSignInError` — thrown by `signInWithGoogle()` and `signInWithApple()`
only. Its raw values are the Web SDK's popup error codes:

| Case | What happened | What to do |
| - | - | - |
| `closed` | The user dismissed the sheet, or the calling task was cancelled. | Nothing to clean up. |
| `unavailable` | No presentation anchor was set (or its window has gone away), `redirectUrl` is not a custom scheme, or iOS refused to start the session. | Call `setAuthPresentationAnchor(_:)` and use a custom-scheme redirect URL. |
| `signInInProgress` | Another `signInWith*` call is still running. | Ignore the extra tap. |
| `callbackIncomplete` | The sheet returned a callback that carried no sign-in result for this instance. | Retry the sign-in; report it if it persists. |

`PortalCredentialError` — a credential that could not be turned into a token.
Branch on `requiresReauthentication`, or on `reason` for the value shared with
the other Portal SDKs:

| Case | `reason` | What happened | What to do |
| - | - | - | - |
| `sessionInvalidated` | `SESSION_INVALIDATED` | The session is known to be dead — cleared by `clearSession()`, or rejected by the backend. | Sign in again. `requiresReauthentication` is `true`. |
| `unavailable` | `CREDENTIAL_UNAVAILABLE` | The credential produced an empty token. | Check that `Portal` was constructed with a valid credential. |
| `providerFailure(underlying:)` | `CREDENTIAL_PROVIDER_FAILURE` | A custom `PortalCredentials` threw from `getToken()`. | Retry, or sign in again. |
| `invalidApiKey` | `nil` | `Portal` was constructed with an empty Client API Key. Raised at construction. | Pass a Client API Key or `credentials`. |

Transport failures are not remapped. Every `401` arrives as
`PortalRequestsError.unauthorized`. From any `PortalAuth` call it can mean the
Auth Environment ID is wrong or authentication is switched off for the
environment; beyond that, what it means depends on the call that threw it:

| Call | A `401` also means |
| - | - |
| `loginWith*`, `signInWith*` | The `redirectUrl` is not allow-listed, or a provider is enabled but incompletely configured. |
| `sendMagicLink(_:)` | The `redirectUrl`, `fromEmail` or `templateId` is not accepted for this environment. |
| `handleRedirect(_:)`, `signInWith*` | The grant was already used, has expired, was issued for another environment, or its sign-in method has since been disabled. |
| `verifyTotp(_:userJwt:)` | The code was rejected, or the `userJwt` has expired or been used. |
| Any `Portal` call | The session was rejected. `onSessionInvalidated` fires. |

```swift theme={null}
import PortalSwift

/// - Parameter completingSignIn: `true` when the error came from `handleRedirect(_:)`.
///   A `401` means a spent or expired grant only there; from `signInWith*` it can also
///   be a configuration problem, so it falls through to the generic message.
func describe(_ error: Error, completingSignIn: Bool) -> String {
    switch error {
    case PortalAuthError.authMethodUnavailable:
        return "That sign-in method is not available."
    case PortalAuthError.authenticationFailed, PortalAuthSignInError.closed:
        return "Sign-in was not completed."
    case PortalAuthError.rateLimited:
        return "Too many sign-in links were sent. Please wait a minute and try again."
    case let credentialError as PortalCredentialError:
        return credentialError.requiresReauthentication
            ? "Your session ended. Please sign in again."
            : "Could not read your session. Please try again."
    case PortalRequestsError.unauthorized:
        return completingSignIn
            ? "That sign-in link has expired. Please start a new sign-in."
            : "That request was rejected. Please try again."
    default:
        return "Something went wrong."
    }
}
```

## Keep credentials out of logs

* Never log the grant token, the raw redirect URL, the `userJwt`, the
  `totpLink`, the session token or the user's email address. Safe to log:
  `endUserId`, `clientId`, the Auth Environment ID and the auth method.
* The `PortalSession` that `PortalAuth` returns, `TotpRequiredResult` and
  `PortalCredentialError` redact their secrets from `String(describing:)`,
  `String(reflecting:)` and `dump`, so printing a result or an error does not
  leak the token, the `userJwt` or the `totpLink`.
* The Auth Environment ID identifies an environment; it does not authenticate a
  caller. Treat it like an OAuth client ID: keep it in configuration, but do not
  rely on it staying secret.

## Next Steps

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

For every `PortalAuth` method, see the [PortalAuth reference](../reference/portalauth).
For the dashboard and provider configuration behind this flow, see
[Authentication](../../../resources/authentication/overview).
