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 anASWebAuthenticationSessionsheet 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 tohandleRedirect(_:). Email magic links always complete this way.
PortalAuth never touches wallets, MPC or signing, and
never creates a Portal.
Client Auth requires PortalSwift
8.0.0 or later.Prerequisites
- Authentication enabled for your environment, with at least one sign-in method and your Auth Environment ID to hand (see Enable authentication)
- The sign-in methods you want configured — Email magic links, Google OAuth, or Apple OAuth
- A redirect URL on your environment’s allow list (see Redirect URLs)
- A working Portal integration (see 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.
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 ofmyapp://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.
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.
Create a PortalAuth
Create one long-livedPortalAuth and reuse it.
A blank
authEnvironmentId or redirectUrl throws
PortalAuthError.invalidArgument(name:). The initializer makes no network or
Keychain call.
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.
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.
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.
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.
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 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:
- SwiftUI
- UIKit (scene delegate)
- UIKit (app delegate)
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.
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:
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 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 ascredentials. 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.
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, viaautoCreateWallet.
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.
End the session
There are three ways a session ends, and they are not interchangeable.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.
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 a401, 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:
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.
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:
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, thetotpLink, the session token or the user’s email address. Safe to log:endUserId,clientId, the Auth Environment ID and the auth method. - The
PortalSessionthatPortalAuthreturns,TotpRequiredResultandPortalCredentialErrorredact their secrets fromString(describing:),String(reflecting:)anddump, so printing a result or an error does not leak the token, theuserJwtor thetotpLink. - 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 everyPortalAuth method, see the PortalAuth reference.
For the dashboard and provider configuration behind this flow, see
Authentication.