Skip to main content
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. 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.
Client Auth requires PortalSwift 8.0.0 or later.

Prerequisites

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.
Keychain items survive app deletion, so a reinstalled app can find the previous install’s session. See Restore the session on launch for how to start a fresh install from a clean slate.

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

Create a PortalAuth

Create one long-lived PortalAuth and reuse it.
A blank authEnvironmentId or redirectUrl throws PortalAuthError.invalidArgument(name:). The initializer makes no network or Keychain call.
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.
isAccountAbstracted is fixed when you construct PortalAuth, and it only takes effect on a user’s first sign-in. A returning user keeps the client they already have.

Check which methods are enabled

getMethods() reports what your environment allows, so you can render only the buttons that will work.
Methods a newer backend has added but this SDK version does not recognize are dropped from allowedAuthMethods rather than surfaced. autoCreateWallet is a signal for your app — nothing in the SDK or the API acts on it. See Create or reuse the wallet.
A two-factor requirement is not visible here. It only appears when a sign-in is completed, so every completion path must be ready for it.

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

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.
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.
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.
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.
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.
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.
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.
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.
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:
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.
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.
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() 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:
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(_:):
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.
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.
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.
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.

Initialize Portal from the session

Pass the session as credentials. Everything else about Portal is unchanged, including how you register backup methods.
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, which builds the Portal and subscribes to invalidation in one step.
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.
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.

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.
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 for the full wallet lifecycle, and Manage wallet lifecycle states for the helpers used above.

Restore the session on launch

restoreSession() rebuilds the session from the Keychain, without a network call, so a returning user does not sign in again.
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.
A restored session is a credential worth trying, not proof that it is still valid. Validity is only discovered on the first authenticated call, which invalidates the credential on a 401.

End the session

There are three ways a session ends, and they are not interchangeable.
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.
clearPersistedSession() is not a sign-out. It stops a future restoreSession() from returning the session, but a Portal already holding the credential keeps signing transactions. To sign a user out, call portal.clearSession().
There is no server-side revoke endpoint, so both operations are local sign-outs. The token itself stays valid until the backend expires it: every authenticated request extends it to 24 hours later, until seven days after it was issued. See API Keys for session lifetimes.
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.

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:
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.
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.
As with a sign-out, an invalidated session does not delete the wallet’s signing shares — only the credential is gone. Wallet state cannot be read without a session, so treat it as unknown until the user signs in again rather than as evidence the wallet is missing.

Handle errors

Client Auth surfaces three error types, plus the transport’s own errors. PortalAuthError — a sign-in that could not be completed:
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.
PortalAuthSignInError — thrown by signInWithGoogle() and signInWithApple() only. Its raw values are the Web SDK’s popup error codes: 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: 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:

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 and send tokens. For every PortalAuth method, see the PortalAuth reference. For the dashboard and provider configuration behind this flow, see Authentication.