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

> Let Portal sign your end users in, without a Client Session Token ever reaching your page.

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](../../../resources/authentication-and-api-keys).

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.

<Note>
  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.
</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, pointing at a callback route in your app (see [Redirect URLs](../../../resources/authentication/redirect-urls))
* A working Portal integration (see [Getting Started](./getting-started))

## Choose an authentication mode

The Web SDK accepts four credential modes. They are mutually exclusive.

| Mode         | Who identifies the user | Backend call needed                          |
| ------------ | ----------------------- | -------------------------------------------- |
| `apiKey`     | You                     | Yes — create the client                      |
| `authToken`  | You                     | Yes — fetch a Web OTP per session            |
| `authUrl`    | You                     | Yes — an auth route that redirects to Portal |
| `clientAuth` | **Portal**              | No                                           |

The first three assume you already have a client and are authenticating it; see
[Web authentication methods](./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.

```typescript theme={null}
import Portal from '@portal-hq/web'

export const portal = new Portal({
  host: 'YOUR-CUSTOM-SUBDOMAIN',
  clientAuth: {
    authEnvironmentId: 'YOUR_AUTH_ENVIRONMENT_ID',
    redirectUrl: 'https://example.com/auth/callback',
    // Required only by sendMagicLink()
    magicLink: {
      fromEmail: 'hello@auth.example.com',
      templateId: 'YOUR_TEMPLATE_ID',
    },
    // Optional: whether the client Portal creates uses gas sponsorship
    // isAccountAbstracted: true,
  },
})
```

| Option                | Type                        | Required | Description                                                                                                                                                             |
| --------------------- | --------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `authEnvironmentId`   | `string`                    | Yes      | Your environment's Auth Environment ID. Also the key the session is stored under.                                                                                       |
| `redirectUrl`         | `string`                    | No       | Where Portal returns the user. Optional in the type, but **required** by `getAuthorizeUrl()` and `sendMagicLink()`, so in practice always set it. Must be allow-listed. |
| `magicLink`           | `{ fromEmail, templateId }` | No       | Required by `sendMagicLink()` only. OAuth-only integrations can omit it.                                                                                                |
| `isAccountAbstracted` | `boolean`                   | No       | Whether the client Portal creates for a first-time user uses gas sponsorship. See [Gas sponsorship](../../../resources/authentication/overview#gas-sponsorship).        |

<Note>
  `isAccountAbstracted` only takes effect on a user's first sign-in. A returning
  user keeps the client they already have.
</Note>

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:

| Signal                     | Question it answers                                   |
| -------------------------- | ----------------------------------------------------- |
| `onAuthenticationRequired` | There is no session. Show the sign-in UI.             |
| `onSessionInvalidated`     | A session that was live has ended. Sign the user out. |
| `onReady`                  | The SDK can serve wallet and API calls.               |

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

```tsx theme={null}
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
import type { FC, PropsWithChildren } from 'react'

import type { SessionIdentity } from '@portal-hq/web'

import { portal } from './portal'

const SignedInUser = createContext<{
  endUserId: string | null
  adopt: (identity: SessionIdentity) => void
}>({ endUserId: null, adopt: () => undefined })

export const useSignedInUser = () => useContext(SignedInUser)

/**
 * Mount once, above your routes: `onSessionInvalidated` is not replayed, so a
 * listener inside a route that is unmounted when the session ends never hears
 * about it.
 */
export const SignedInUserProvider: FC<PropsWithChildren> = ({ children }) => {
  const [endUserId, setEndUserId] = useState<string | null>(null)

  // Call this with the result of handleRedirect() or restoreSession().
  const adopt = useCallback((identity: SessionIdentity) => {
    setEndUserId(identity.endUserId)
  }, [])

  // No session: the initial state, and the state after a sign-out.
  useEffect(() => portal.onAuthenticationRequired(() => setEndUserId(null)), [])

  // A live session ended somewhere this app cannot see.
  useEffect(
    () =>
      portal.onSessionInvalidated((reason) => {
        console.warn('❌ session ended:', reason)
        setEndUserId(null)
      }),
    [],
  )

  return (
    <SignedInUser.Provider value={{ endUserId, adopt }}>
      {children}
    </SignedInUser.Provider>
  )
}
```

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

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

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

## Check which methods are enabled

```typescript theme={null}
import type { AuthMethodsResult } from '@portal-hq/web'

import { portal } from './portal'

const loadMethods = async (): Promise<AuthMethodsResult> => {
  const methods = await portal.auth.getMethods()

  console.log('✅ enabled:', methods.allowedAuthMethods) // ['GOOGLE', 'EMAIL_MAGIC_LINK']
  console.log('✅ autoCreateWallet:', methods.autoCreateWallet)

  return methods
}
```

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

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

## 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](#handle-the-unauthenticated-first-state).

```typescript theme={null}
import { AuthMethod } from '@portal-hq/web'

import { portal } from './portal'

const signInWithProvider = async (method: AuthMethod.Google | AuthMethod.Apple) => {
  const { authorizeUrl } = await portal.auth.getAuthorizeUrl(method)

  window.location.assign(authorizeUrl)
}
```

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

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

### Email magic link

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

```typescript theme={null}
import { portal } from './portal'

const sendMagicLink = async (input: string) => {
  // The API requires a lowercase address and rejects anything else with a 400.
  const email = input.trim().toLowerCase()

  await portal.auth.sendMagicLink(email)

  console.log(`✅ magic link sent — check ${email}`)
}
```

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

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

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

```tsx theme={null}
import { useEffect, useRef, useState } from 'react'

import type { TotpRequiredResult } from '@portal-hq/web'

import { portal } from './portal'
import { useSignedInUser } from './SignedInUserProvider'

type Status = 'working' | 'signedIn' | 'totpRequired' | 'ignored' | 'error'

export const AuthCallback = () => {
  const { adopt } = useSignedInUser()
  const [status, setStatus] = useState<Status>('working')
  const [totp, setTotp] = useState<TotpRequiredResult | null>(null)
  const started = useRef(false)

  useEffect(() => {
    const start = () => {
      // Either signal can arrive first — only run once.
      if (started.current) return
      started.current = true

      portal.auth
        .handleRedirect(window.location.href)
        .then((result) => {
          // Not a Client Auth redirect.
          if (!result) {
            setStatus('ignored')
            return
          }

          if (result.status === 'totpRequired') {
            setTotp(result)
            setStatus('totpRequired')
            return
          }

          // The authoritative "signed in" signal.
          adopt(result.session)
          setStatus('signedIn')
        })
        .catch((error: unknown) => {
          console.error('❌ handleRedirect failed:', error)
          setStatus('error')
        })
    }

    const unsubscribeReady = portal.onReady(start)
    const unsubscribeAuth = portal.onAuthenticationRequired(start)

    return () => {
      unsubscribeReady()
      unsubscribeAuth()
    }
  }, [adopt])

  if (status === 'totpRequired' && totp) {
    return <TotpPrompt challenge={totp} />
  }

  return <p>{status === 'error' ? 'Sign-in failed.' : 'Signing you in…'}</p>
}
```

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

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

```tsx theme={null}
import { useState } from 'react'
import { QRCodeSVG } from 'qrcode.react'

import type { TotpRequiredResult } from '@portal-hq/web'

import { portal } from './portal'
import { useSignedInUser } from './SignedInUserProvider'

export const TotpPrompt = ({ challenge }: { challenge: TotpRequiredResult }) => {
  const { adopt } = useSignedInUser()
  const [code, setCode] = useState('')
  const [error, setError] = useState<string | null>(null)

  const submit = async () => {
    try {
      const result = await portal.auth.verifyTotp(code, challenge.userJwt)

      // Same authoritative signal as the redirect path.
      adopt(result.session)
    } catch {
      // A rejected code does not consume the userJwt, so prompting again is normal.
      setError('That code was not accepted. Try the next one from your app.')
      setCode('')
    }
  }

  return (
    <div>
      {challenge.totpLink && <QRCodeSVG value={challenge.totpLink} />}
      <input value={code} onChange={(event) => setCode(event.target.value)} />
      <button onClick={submit}>Verify</button>
      {error && <p>{error}</p>}
    </div>
  )
}
```

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

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

For what the second factor is and how to reset an enrollment, see
[Two-factor authentication](../../../resources/authentication/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:

```typescript theme={null}
import type { SessionIdentity } from '@portal-hq/web'

import { portal } from './portal'

const restore = async (
  adopt: (identity: SessionIdentity) => void,
): Promise<SessionIdentity | null> => {
  const identity = await portal.auth.restoreSession()

  if (!identity) {
    console.log('✅ nothing stored — show the sign-in UI')
    return null
  }

  adopt(identity)

  return identity
}
```

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

## Sign out

```typescript theme={null}
import { portal } from './portal'

const signOut = async () => {
  await portal.auth.clearSession()
}
```

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

<Note>
  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](../../../resources/authentication-and-api-keys)
  for session lifetimes.
</Note>

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

## 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](#handle-the-unauthenticated-first-state),
which is where the example above puts it.

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

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

## Handle errors

Credential failures are normalized to `PortalCredentialError`. Branch on its
`reason`.

| Reason               | What happened                                                  | What to do                                                            |
| -------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------- |
| `SessionInvalidated` | The session is known to be dead.                               | Re-authenticate. `error.requiresReauthentication` is `true`.          |
| `Unavailable`        | Resolution produced nothing usable.                            | Check that `Portal` was configured with a credential or `clientAuth`. |
| `ProviderFailure`    | The credential provider itself failed, such as a storage read. | Retry, or sign out and sign in again.                                 |

```typescript theme={null}
import { PortalCredentialError } from '@portal-hq/web'

const describe = (error: unknown): string => {
  if (PortalCredentialError.is(error)) {
    return error.requiresReauthentication
      ? 'Your session ended. Please sign in again.'
      : 'Could not read your session. Please try again.'
  }

  return 'Something went wrong.'
}
```

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

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.

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

## Next Steps

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

For the dashboard and provider configuration behind this flow, see
[Authentication](../../../resources/authentication/overview).
