Skip to main content
With Client Auth, Portal identifies your end user and creates their client for you, so you need neither a login system of your own nor a server-side call to mint a Client Session Token. The Web SDK’s model differs from every other Portal SDK in one important way, and the rest of this guide follows from it:
  • The Client Session Token never reaches your page. The SDK runs in a hidden iframe on the Portal origin, and the token stays there. portal.apiKey stays undefined.
  • portal.auth is the host-facing API. Every method proxies into the iframe, because /api/v3/auth/* is not reachable cross-origin from your page.
  • restoreSession() returns identity, not a credential — an endUserId and nothing more.
So there is no token for your app to store, forward, or accidentally leak. There is also nothing to pass to the Portal constructor after signing in: the same instance you configured is the one that becomes authenticated.
Client Auth requires a version of @portal-hq/web whose Portal accepts a clientAuth option. If portal.auth is undefined on your installed version, upgrade to the latest release.

Prerequisites

Choose an authentication mode

The Web SDK accepts four credential modes. They are mutually exclusive. The first three assume you already have a client and are authenticating it; see Web authentication methods. Use clientAuth when you want Portal to identify the user and create the client as part of the sign-in.

Configure Portal for Client Auth

Pass clientAuth instead of a credential. The iframe boots unauthenticated and waits for a sign-in.
isAccountAbstracted only takes effect on a user’s first sign-in. A returning user keeps the client they already have.
Resolve these values from your own backend rather than hardcoding them in client-side bundles. The Auth Environment ID is safe to treat as public — like an OAuth client ID — but the redirect and template values are configuration you will want to change per environment.

Handle the unauthenticated first state

Read this before writing any sign-in code. A Portal configured for Client Auth posts authenticationRequired during configuration and stops there — it does not become ready until a session is adopted. onAuthenticationRequired is replayed to late subscribers, so you cannot miss it by subscribing a moment too late. Three signals are available, and each answers a different question: onReady answers a question about the SDK, not about your user. Do not treat it as proof that someone is signed in — see the warning below. What a session begins is the result of handleRedirect() or restoreSession(). Those return values are the authoritative signal that someone is now signed in, so keep the answer in one place your app owns:
Do not derive “signed in” from onReady. It is emitted at most once per page load — adopting a session posts a separate internal signal, and portal.auth.clearSession() deliberately leaves readiness unchanged — so an app that sets its signed-in flag from onReady gets stuck in a signed-out state on the second sign-in of the same page load. Set it from the handleRedirect() or restoreSession() result instead, as above.
Do not start a sign-in while a session is already live. A second sign-in mints a second session and can adopt a different end user on top of the current one. Offer your sign-in controls only while endUserId is null, and require a sign-out first.
Until a session is adopted the iframe never reaches ready, so a loading state that waits only for onReady never resolves. Gate wallet and API calls on onReady; gate your sign-in UI on onAuthenticationRequired.

Check which methods are enabled

autoCreateWallet is a signal for your app — nothing in the SDK or the API acts on it. Check for an existing wallet before creating one; a returning end user keeps the wallet they already have. See Create a wallet.
A two-factor requirement is not visible here. It only appears when a sign-in is completed, so your callback route must always be ready for it.

Start a sign-in

Google or Apple

getAuthorizeUrl() returns a provider URL for your app to navigate to. The sign-in completes later, on your callback route. Offer these controls only while nobody is signed in — see Handle the unauthenticated first state.
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.
The SDK also exports signInWithGoogle() and signInWithApple(), which run the sign-in in a popup. They cannot currently complete a Google or Apple sign-in: both providers serve their sign-in pages with Cross-Origin-Opener-Policy: same-origin, which switches the popup’s browsing context group and permanently severs window.opener, so the popup has no way to hand its callback URL back. Use the full-page redirect above.
sendMagicLink() resolves once Portal has handed the email off for delivery. It tells you nothing about the eventual sign-in, which completes when the user opens the link in a browser.
Lowercase and trim the address before you call sendMagicLink(). The API rejects a non-lowercase address with a 400, and the SDK passes what you give it through unchanged — so a plain text input breaks for any user who capitalizes.
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.

Build the callback route

Add a route at the exact URL on your allow list. It hands its own URL to handleRedirect(), which owns extracting the grant and exchanging it. Because the page loads fresh, wait for the SDK before calling it — and subscribe to both onReady and onAuthenticationRequired, since either can fire first depending on whether a session was already stored.
Route every redirect through exactly one handleRedirect() call. A grant is spent the moment the backend sees it, so a second call on the same URL fails — which looks like a real authentication failure to your user. A reloaded callback page is the common way this happens, so guard the call as above.
handleRedirect() resolves null when the URL does not carry a Client Auth grant, so it is safe to reach from a shared route.

Handle two-factor authentication

If your environment requires a second factor, a completed sign-in resolves with status: 'totpRequired' instead of a session. totpLink is an otpauth:// URI on a user’s first sign-in — render it as a QR code for their authenticator app — and null once they are enrolled.
Rendering the QR code is your app’s job; Portal does not ship a QR component. Any library works — qrcode.react is used above — and challenge.totpLink should be passed verbatim.
A rejected code, an expired userJwt, and one that has already been used all surface the same way, so the SDK cannot tell you which happened. Prompt for the next code from the authenticator app; if that keeps failing, start a new sign-in. There is no way to refresh a userJwt.
For what the second factor is and how to reset an enrollment, see Two-factor authentication.

Restore the session

restoreSession() asks the iframe whether it still holds a stored session for this auth environment. The SDK never calls it for you. Its result is authoritative in the same way the redirect result is, so hand it to the same adopt path:
restoreSession() returns identity information — { endUserId } — and never a credential. The Client Session Token stays on the Portal origin. It is also not proof the session is still valid; validity is only discovered on the first authenticated call.

Sign out

This drops the in-memory token and deletes the stored copy for this auth environment, and the iframe returns to its unauthenticated state — so your onAuthenticationRequired handler fires again and clears the signed-in state you set with adopt.
There is no server-side revoke endpoint, so this is a local sign-out; the token stays valid until the backend expires it. clearSession() deliberately does not fire onSessionInvalidated — you already know about a sign-out you asked for. See Authentication and API Keys for session lifetimes.
Signing out does not delete the wallet’s signing shares. 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 session with a 401, it ended somewhere your app cannot see and the user has to sign in again. The subscription belongs in SignedInUserProvider from Handle the unauthenticated first state, which is where the example above puts it.
Subscribe from an app-level component or provider that outlives your individual routes. Unlike onAuthenticationRequired, this event is not replayed to late subscribers, so if the only listener lives inside a route or tab that is unmounted when the session ends, the event is missed and your app carries on looking signed in.
onSessionInvalidated() does not cover every failure. It fires when a request that actually carried the session is rejected, but a failure inside an MPC operation may surface only as an error from that call. Handle errors from the call itself as well as subscribing here.
As with a sign-out, an invalidated session does not delete the wallet’s signing shares — only the credential is gone. Wallet state queries cannot be answered without a session, so treat them as unknown until the user signs in again rather than as evidence the wallet is missing.

Handle errors

Credential failures are normalized to PortalCredentialError. Branch on its reason.
Use PortalCredentialError.is(error), never instanceof. The type is identified by a cross-copy brand, so instanceof returns false when a dependency tree resolves two copies of the package — silently downgrading a real SessionInvalidated to an unhandled error.
A failed sign-in — a rejected redirect or a refused grant exchange — surfaces as a plain Error from handleRedirect() rather than a dedicated type, so catch and report it rather than branching on it. Popup helpers throw PortalAuthPopupError, which you will not encounter using the redirect flow above.
The Web and React Native Client Auth APIs are not the same shape — different entry point, different sign-out, and a different restoreSession() return type. Do not port code between them; use each platform’s guide.

Next Steps

Now that your user is signed in, create a wallet and send tokens. For the dashboard and provider configuration behind this flow, see Authentication.