> ## 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 asks Portal to start a sign-in, receives the completed sign-in as
a deep link, and gets back a `PortalSession` that it passes straight to
`Portal`. No login system of your own, and no server-side call to mint a
[Client Session Token](../../../resources/authentication-and-api-keys).

Your app owns three things the SDK deliberately does not: **when** a sign-in
starts, **how** the authorize URL is opened, and **how** the redirect gets back
to you. `PortalAuth` never opens a browser and never registers a deep-link
handler.

<Note>
  Client Auth requires a version of `@portal-hq/core` that exports `PortalAuth`.
  If `PortalAuth` is not exported by 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 (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 `@portal-hq/core`, and
the session is stored with `react-native-keychain`, which
`@portal-hq/keychain` already requires.

## Register your redirect

The OS only delivers a redirect to your app if you register its scheme
natively. For a redirect URL of `myapp://auth/callback`, register the scheme and
make sure a returning redirect reaches the running app.

<Tabs>
  <Tab title="iOS">
    Register the scheme in `Info.plist`:

    ```xml theme={null}
    <key>CFBundleURLTypes</key>
    <array>
      <dict>
        <key>CFBundleTypeRole</key>
        <string>Editor</string>
        <key>CFBundleURLSchemes</key>
        <array>
          <string>myapp</string>
        </array>
      </dict>
    </array>
    ```

    Registering the scheme is not enough on its own. A cold start arrives through
    `launchOptions`, which `Linking.getInitialURL()` reads, but the usual Client
    Auth path — the browser returning to an app that is still running in the
    background — needs your `AppDelegate` to forward the URL:

    ```swift theme={null}
    func application(
      _ app: UIApplication,
      open url: URL,
      options: [UIApplication.OpenURLOptionsKey: Any] = [:]
    ) -> Bool {
      NotificationCenter.default.post(
        name: Notification.Name("RCTOpenURLNotification"),
        object: self,
        userInfo: ["url": url.absoluteString]
      )

      return true
    }
    ```

    Without this, `Linking.addEventListener('url', …)` never fires for a warm or
    backgrounded app and the sign-in appears to hang.

    The notification is posted directly because `React-RCTLinking` ships only its
    `.m`/`.mm` sources, so `RCTLinkingManager.h` is not a public header and the
    class is not visible from Swift. In an Objective-C `AppDelegate` you can call
    `[RCTLinkingManager application:app openURL:url options:options]` instead — it
    posts the same notification.
  </Tab>

  <Tab title="Android">
    Add the intent filter to the activity that should receive the redirect, and set
    both activity attributes shown here:

    ```xml theme={null}
    <activity
      android:name=".MainActivity"
      android:launchMode="singleTask"
      android:exported="true">

      <!-- your existing MAIN/LAUNCHER intent-filter stays as it is -->

      <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="myapp" android:host="auth" />
      </intent-filter>
    </activity>
    ```

    * `android:exported="true"` is required on Android 12 and later for an activity
      that declares an intent filter. Without it the app fails to install.
    * `android:launchMode="singleTask"` delivers the redirect to the activity that
      is already running instead of starting a second instance, which is what lets
      your `Linking` listener receive it.
  </Tab>
</Tabs>

<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
  native registration above. 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. It holds no in-flight state, so
concurrent sign-ins are safe and a sign-in survives your app being backgrounded.

```typescript theme={null}
import { PortalAuth } from '@portal-hq/core'

export const portalAuth = new PortalAuth({
  authEnvironmentId: 'YOUR_AUTH_ENVIRONMENT_ID',
  redirectUrl: 'myapp://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`                    | Yes      | Where Portal returns the user. Must be allow-listed.                                                                                                             |
| `magicLink`           | `{ fromEmail, templateId }` | No       | Required by `sendMagicLink()` only. OAuth-only apps 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` 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.

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

import { portalAuth } from './portalAuth'

const loadMethods = async (): Promise<AuthMethodsResult> => {
  const methods = await portalAuth.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. 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 your redirect handler must always be ready for it.
</Note>

## Start a sign-in

### Google or Apple

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

```typescript theme={null}
import { Linking } from 'react-native'

import { AuthMethod } from '@portal-hq/core'
import { portalAuth } from './portalAuth'

const signInWithProvider = async (method: AuthMethod.Google | AuthMethod.Apple) => {
  const { authorizeUrl } =
    method === AuthMethod.Google
      ? await portalAuth.loginWithGoogle()
      : await portalAuth.loginWithApple()

  await Linking.openURL(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>

<Note>
  `Linking.openURL` hands off to the system browser, which is what the Portal
  example app does and what Google's OAuth policy requires. You can instead use a
  native auth-session API such as `ASWebAuthenticationSession` or Android Custom
  Tabs, but be careful: on Android the Custom Tab redirect **also** fires your
  intent filter, so the session's own result and your deep-link listener will both
  deliver the same redirect. Feed it to `handleRedirect()` from one path only —
  see [Complete the sign-in](#complete-the-sign-in).
</Note>

### Email magic link

`sendMagicLink()` resolves 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.

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

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

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

## Complete the sign-in

`handleRedirect()` is the single completion path for every method. Give it the
incoming URL and it either resolves a result, resolves `null` because the URL
was not a Client Auth redirect, or throws.

Register the listener before you open any browser, and read `getInitialURL()`
once per process in case the redirect is what launched your app. Keep that
latch at module scope rather than in component state: `getInitialURL()` keeps
returning the launch URL for the lifetime of the process, so a per-component
guard lets a remount replay a grant that has already been spent.

```typescript theme={null}
import { useEffect, useState } from 'react'
import { Linking } from 'react-native'

import type { PortalSession, TotpRequiredResult } from '@portal-hq/core'
import { PortalAuthError, PortalAuthErrorReason } from '@portal-hq/core'

import { portalAuth } from './portalAuth'

/**
 * Module scope, deliberately: `getInitialURL()` keeps returning the URL the app
 * was launched with, so a component that remounts would hand the same, already
 * spent, grant to `handleRedirect()` again and surface a `GrantRejected` that
 * is not a real failure. A `useRef` only guards one mount.
 */
let initialUrlRead = false

export const useClientAuth = () => {
  const [session, setSession] = useState<PortalSession | null>(null)
  const [totpChallenge, setTotpChallenge] = useState<TotpRequiredResult | null>(
    null,
  )

  useEffect(() => {
    const handleUrl = async (url: string) => {
      try {
        const result = await portalAuth.handleRedirect(url)

        // Not a Client Auth redirect — let the rest of your router handle it.
        if (!result) return

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

        setSession(result.session)
      } catch (error) {
        if (PortalAuthError.is(error)) {
          if (error.reason === PortalAuthErrorReason.ProviderRejected) {
            console.error('❌ the user did not complete the provider sign-in')
          } else if (error.reason === PortalAuthErrorReason.GrantRejected) {
            console.error('❌ this sign-in link is spent or expired — start a new sign-in')
          }
          return
        }

        console.error('❌ handleRedirect failed:', error)
      }
    }

    const subscription = Linking.addEventListener('url', ({ url }) => {
      void handleUrl(url)
    })

    // Cold start: the redirect may be what launched the app. Once per process.
    if (!initialUrlRead) {
      initialUrlRead = true
      Linking.getInitialURL()
        .then((url) => {
          if (url) void handleUrl(url)
        })
        .catch((error: unknown) => console.error('❌ getInitialURL failed:', error))
    }

    return () => subscription.remove()
  }, [])

  return { session, totpChallenge }
}
```

<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 throws
  `GrantRejected` — which looks like a real authentication failure to your user.
  Operating systems do re-deliver deep links, so this matters in practice.
</Warning>

`handleRedirect()` resolves `null` for any URL that is not this instance's
redirect, which makes it safe to call from a shared deep-link handler alongside
your app's other routes.

## Handle two-factor authentication

If your environment requires a second factor, a completed sign-in resolves with
`status: 'totpRequired'` instead of a session. Nothing is persisted yet.

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

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

```typescript theme={null}
import type { PortalSession, TotpRequiredResult } from '@portal-hq/core'

import { portalAuth } from './portalAuth'

const verifyTotp = async (
  challenge: TotpRequiredResult,
  code: string,
): Promise<PortalSession | null> => {
  try {
    const result = await portalAuth.verifyTotp(code, challenge.userJwt)

    // The same session the non-TOTP path returns. Pass it to createPortal().
    return result.session
  } catch (error) {
    // A rejected code does not consume the userJwt, so prompting again is normal.
    console.error('❌ verifyTotp failed — ask for the next code:', error)
    return null
  }
}
```

Wired into the hook from the previous section, both paths converge on the one
`createPortal` from
[Initialize Portal from the session](#initialize-portal-from-the-session):

```typescript theme={null}
const { session, totpChallenge } = useClientAuth()

const submitTotpCode = async (code: string) => {
  if (!totpChallenge) return

  const verified = await verifyTotp(totpChallenge, code)

  if (verified) {
    // Continue exactly as for a session from handleRedirect().
    const portal = createPortal(verified)
  }
}
```

<Note>
  Rendering the QR code is your app's job; Portal does not ship a QR component.
  Any React Native QR library works — pass `challenge.totpLink` to it 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).

## Initialize Portal from the session

Pass the session as `credentials`. Everything else about `Portal` is unchanged —
including `backup`, which is still required.

```typescript theme={null}
import { BackupMethods, Portal } from '@portal-hq/core'
import type { PortalSession } from '@portal-hq/core'
import GoogleDriveStorage from '@portal-hq/gdrive-storage'
import { PasswordStorage } from '@portal-hq/utils/src/definitions'

const createPortal = (session: PortalSession): Portal => {
  const gDriveStorage = new GoogleDriveStorage({
    androidClientId: 'YOUR_ANDROID_CLIENT_ID',
    iosClientId: 'YOUR_IOS_CLIENT_ID',
  })

  return new Portal({
    credentials: session,
    backup: {
      [BackupMethods.GoogleDrive]: gDriveStorage,
      [BackupMethods.Password]: new PasswordStorage(),
    },
  })
}
```

`credentials` and `apiKey` are mutually exclusive — passing both throws.

<Warning>
  `portal.apiKey` is an empty string on a session-backed instance and is
  deprecated. Do not read it or forward it anywhere, such as into a webview: it
  carries no credential.
</Warning>

## Create or reuse the wallet

A returning end user keeps the client and wallet they already have, so check for
addresses before creating anything. Create a wallet only when the environment
asks you to, via `autoCreateWallet`.

```typescript theme={null}
import type { Portal } from '@portal-hq/core'
import type { AddressesByNamespace } from '@portal-hq/utils/types'

import { portalAuth } from './portalAuth'

const resolveWallet = async (portal: Portal): Promise<AddressesByNamespace | undefined> => {
  const existing = await portal.addresses

  if (existing && Object.values(existing).some(Boolean)) {
    console.log('✅ reusing the existing wallet')
    return existing
  }

  const { autoCreateWallet } = await portalAuth.getMethods()

  if (!autoCreateWallet) {
    console.log('✅ autoCreateWallet is off — not creating a wallet')
    return undefined
  }

  const created = await portal.createWallet()
  console.log('✅ wallet created:', created)

  return created
}
```

See [Create a wallet](./create-a-wallet) for the full wallet lifecycle.

## Restore the session on launch

`restoreSession()` rebuilds the session from secure storage so a returning user
does not sign in again.

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

const restore = async () => {
  try {
    const session = await portalAuth.restoreSession()

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

    return session
  } catch (error) {
    // Storage held something unreadable. Clear it and sign in again.
    console.error('❌ restoreSession failed:', error)
    await portalAuth.clearPersistedSession()
    return null
  }
}
```

<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. Note also
  that corrupt storage makes `restoreSession()` **reject** rather than resolve
  `null` — `null` means "not signed in", a rejection means "storage is broken".
</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. A live `Portal` keeps working. | You have no `Portal` in hand — clearing corrupt storage, or a leftover before a fresh sign-in. |
| `portal.onSessionInvalidated()`      | Fires when the backend ends the session.                     | Reacting to a sign-out you did not initiate.                                                   |

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

import { portalAuth } from './portalAuth'

// Signing the current user out.
const signOut = async (portal: Portal) => {
  await portal.clearSession()
}

// Clearing storage when there is no Portal to clear through.
const clearStoredSession = async () => {
  await portalAuth.clearPersistedSession()
}
```

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

<Note>
  Neither operation deletes 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 credential with a `401`, the session ended
somewhere your app cannot see and the user has to sign in again.

```typescript theme={null}
import { useEffect } from 'react'

import type { Portal } from '@portal-hq/core'

const useSessionInvalidated = (portal: Portal | null, signOut: () => void) => {
  useEffect(() => {
    if (!portal) return

    return portal.onSessionInvalidated(signOut)
  }, [portal, signOut])
}
```

The returned function unsubscribes. The callback fires at most once for an
instance, and it is **not** fired by `portal.clearSession()` — you already know
about a sign-out you asked for.

Subscribe from something that lives as long as the `Portal` it belongs to. The
callback is not replayed, so if the only subscriber is inside a screen that has
been unmounted by the time the session ends, the event is missed and your app
carries on looking signed in.

<Warning>
  `credentials` is set once at construction, so a `Portal` whose session ended
  stays spent: every later call fails with `SessionInvalidated`. After the next
  sign-in, construct a new `Portal` and subscribe again rather than reusing the
  old instance.
</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 two error types. Branch on their `reason`.

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

| Reason             | What happened                                                                                | What to do                                                 |
| ------------------ | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| `ProviderRejected` | The provider sent the user back with an error instead of a grant.                            | The user never finished the sign-in. Offer to start again. |
| `GrantRejected`    | The backend refused the exchange — already used, expired, or issued for another environment. | The grant is spent. Start a new sign-in.                   |

`PortalCredentialError` — a credential that could not be turned into a token:

| 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 constructed with `credentials` or `apiKey`. |
| `ProviderFailure`    | The credential provider itself failed, such as a storage read. | Retry, or clear the stored session and sign in again.               |

```typescript theme={null}
import {
  PortalAuthError,
  PortalAuthErrorReason,
  PortalCredentialError,
} from '@portal-hq/core'

const describe = (error: unknown): string => {
  if (PortalAuthError.is(error)) {
    return error.reason === PortalAuthErrorReason.GrantRejected
      ? 'That sign-in link has expired. Please try again.'
      : 'Sign-in was not completed.'
  }

  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 `PortalAuthError.is(error)` and `PortalCredentialError.is(error)`, never
  `instanceof`. Both types are identified by a cross-copy brand, so `instanceof`
  returns `false` when a dependency tree resolves two copies of
  `@portal-hq/utils` — silently downgrading a real `SessionInvalidated` to an
  unhandled error.
</Warning>

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