Skip to main content
This guide covers building custom authentication UI using the @getpara/web-sdk client directly — no React required. This approach works with any JavaScript framework (Vue, Svelte, Angular, etc.) or plain vanilla JS. Available in v2.13.0+
The simplified methods (authenticateWithEmailOrPhone, authenticateWithOAuth) are long-running — they internally poll for session completion and wait for the user to finish interacting with the portal. Ensure the calling context will not be destroyed while the method is running (e.g. avoid calling from a component that may unmount mid-flow).
While these methods manage the flow end-to-end, you are responsible for opening the portal URLs that Para generates during authentication (for verification, passkey creation, password entry, etc.). Use para.onStatePhaseChange() to listen for these URLs and open them. Passkey URLs must be opened in a popup — WebAuthn does not work in iframes. See Handling State Changes below.

Prerequisites

Install the Web SDK:
npm install @getpara/web-sdk

Client Setup

Create a Para client instance:
import { ParaWeb } from "@getpara/web-sdk";

const PARA_API_KEY = "YOUR_API_KEY";

export const para = new ParaWeb(PARA_API_KEY);

Method Reference

This method must be paired with para.onStatePhaseChange() to handle portal URLs that appear during authentication (verification, passkey, password, PIN). See the guide for your platform for the full state listener pattern.
This method must be paired with para.onStatePhaseChange() to handle portal URLs that appear after OAuth completes (e.g. passkey or password setup for returning users). See the guide for your platform for the full state listener pattern.

Handling State Changes

During authentication, Para’s state machine progresses through phases that require user interaction — either opening portal URLs (for basic login users and biometric flows) or showing a code input (for non-basic-login new signups). You need to subscribe to state changes and handle them yourself.
Passkey URLs must be opened in a popup window, not an iframe. WebAuthn/passkey operations require a top-level browsing context and will fail silently in iframes due to browser security restrictions. Password and PIN URLs can be opened in either a popup or an iframe. Verification URLs can also use either approach.
Use para.onStatePhaseChange() to receive a StateSnapshot. The snapshot contains authPhase (what stage the flow is in) and authStateInfo (URLs and flags for the current stage):
import { para } from "./your-para-client";
import type { StateSnapshot, AuthPhase } from "@getpara/web-sdk";

let lastUrl: string | null = null;
let popup: Window | null = null;

const unsubscribe = para.onStatePhaseChange((snapshot: StateSnapshot) => {
  const { authStateInfo, authPhase } = snapshot;

  // Basic login verification URL — open in popup or iframe
  if (authStateInfo.verificationUrl && authStateInfo.verificationUrl !== lastUrl) {
    lastUrl = authStateInfo.verificationUrl;
    popup = window.open(
      authStateInfo.verificationUrl,
      "ParaVerification",
      "popup,width=400,height=500"
    );
    return;
  }

  // Biometric / security URLs
  const { passkeyUrl, passwordUrl, pinUrl } = authStateInfo;

  // Passkey URLs MUST use a popup — WebAuthn does not work inside iframes
  if (passkeyUrl && passkeyUrl !== lastUrl) {
    lastUrl = passkeyUrl;
    popup = window.open(passkeyUrl, "ParaPasskey", "popup,width=400,height=500");
  } else if (passwordUrl && passwordUrl !== lastUrl) {
    lastUrl = passwordUrl;
    popup = window.open(passwordUrl, "ParaPassword", "popup,width=400,height=500");
  } else if (pinUrl && pinUrl !== lastUrl) {
    lastUrl = pinUrl;
    popup = window.open(pinUrl, "ParaPIN", "popup,width=400,height=500");
  }
});

// Call unsubscribe() when your auth UI is torn down
When authPhase is 'awaiting_account_verification', the user is a non-basic-login new signup who has been sent an OTP code via email or SMS. There is no URL to open — you must show a code input field and call para.verifyNewAccount({ verificationCode }). If the user needs a new code, call para.resendVerificationCode({ type: 'SIGNUP' }). The simplified method is waiting for this step to complete before it proceeds. See the full example below.

authStateInfo Fields

FieldTypeDescription
verificationUrlstring | nullPortal URL for basic login users to complete auth (OTP, passkey, etc.) in the hosted portal. Only set during awaiting_session_start for basic login flows. Can be opened in a popup or iframe.
passkeyUrlstring | nullPortal URL for passkey login or creation. Must be opened in a popup — WebAuthn does not work in iframes.
passwordUrlstring | nullPortal URL for password login or creation. Can be opened in a popup or iframe.
pinUrlstring | nullPortal URL for PIN login or creation. Can be opened in a popup or iframe.
isPasskeySupportedbooleanWhether the user’s device supports passkeys/WebAuthn.
isNewUserbooleanWhether this is a new signup flow.

State Phase Reference

The StateSnapshot returned by para.onStatePhaseChange() contains three phase fields that tell you exactly where in the flow the user is. Use these to drive your UI.

corePhase — Top-Level Lifecycle

PhaseDescription
setting_upPara is initializing. Show a loading indicator.
auth_flowThe user is in the authentication flow. Refer to authPhase for details.
wallet_flowAuthentication succeeded; wallets are being set up. Refer to walletPhase for details.
authenticatedThe user is fully logged in with wallets ready.
guest_modeThe user is in guest mode (no full authentication).
logging_outA logout is in progress.
errorAn unrecoverable error occurred. Check snapshot.error.

authPhase — Authentication Flow Detail

PhaseDescriptionWhat to show
unauthenticatedNo auth in progress.Login / signup form.
authenticating_email_phoneEmail/phone auth initiated, routing in progress.Loading indicator.
authenticating_oauthStandard OAuth flow in progress.Loading indicator or “Waiting for provider…”.
authenticating_telegramTelegram auth in progress.Telegram popup/iframe.
authenticating_farcasterFarcaster auth in progress.Farcaster QR code.
processing_authenticationServer is processing the auth request.Loading indicator.
verifying_new_accountVerification code is being validated.Loading indicator.
awaiting_account_verificationNew user (non-basic-login) needs to enter an OTP verification code sent to their email or phone. No URL to open — authStateInfo will not have a verificationUrl.Show a code input field. Call para.verifyNewAccount({ verificationCode }) when submitted.
awaiting_session_startPortal URLs are ready. For basic login users, authStateInfo.verificationUrl is set (user completes auth in the portal). For passkey/password/PIN users, passkeyUrl, passwordUrl, and/or pinUrl are set.Open the appropriate URL. Passkey URLs must use a popup on web.
waiting_for_sessionSession polling is active — waiting for the user to complete in the portal.”Completing setup…” loading state.
authenticatedAuth flow is complete. (Core may still be in wallet_flow.)Transition to authenticated UI, or show wallet loading indicator.
guest_modeThe user is in guest mode (no full authentication).Guest UI.
errorAuth flow failed. Check snapshot.error.Error message with retry option.
Basic login vs passkey/password/PIN: Basic login users complete their entire authentication through a portal URL — the verificationUrl handles OTP entry, passkey creation, etc. in a single hosted flow. Passkey, password, and PIN users go through a two-step process where OTP verification happens in your app (via awaiting_account_verification) and biometric setup happens in the portal (via awaiting_session_start).

walletPhase — Wallet Setup Detail

PhaseDescription
checking_wallet_stateChecking if wallets need to be created.
needs_walletsWallets need to be created for this user.
claiming_walletsClaiming pregenerated wallets that were created for this user before signup.
creating_walletsWallets are being created.
waiting_for_walletsWaiting for wallet creation or claiming to complete.
setting_up_after_loginPerforming post-login wallet setup (e.g. refreshing session, syncing wallet state).
wallets_readyWallets are created and ready to use.
no_wallets_neededUser already has wallets; nothing to create.
errorWallet creation failed. Check snapshot.error.

Email / Phone Authentication

Use para.authenticateWithEmailOrPhone() to authenticate a user by email or phone. The method handles the complete flow: determining whether the user is new or returning, session polling, and wallet creation. Combine it with the state listener above to open portal URLs, and handle OTP input when authPhase is 'awaiting_account_verification'.
import { para } from "./your-para-client";
import type { AuthPhase } from "@getpara/web-sdk";

// Track auth phase for UI updates
let currentAuthPhase: AuthPhase = "unauthenticated";

const unsubscribe = para.onStatePhaseChange((snapshot) => {
  currentAuthPhase = snapshot.authPhase;
  // Update your UI based on currentAuthPhase
  renderAuthUI();
});

async function handleEmailAuth(email: string) {
  try {
    const result = await para.authenticateWithEmailOrPhone({
      auth: { email },
    });

    if (result.hasCreatedWallets && result.recoverySecret) {
      // Non-basic-login new user — display or store the recovery secret
      console.log("Recovery secret:", result.recoverySecret);
    }

    // User is now fully authenticated
    console.log("Auth info:", result.authInfo);
  } catch (error) {
    console.error("Authentication failed:", error);
  } finally {
    unsubscribe();
  }
}

// When authPhase is "awaiting_account_verification", show a code input
// and call this with the user's code:
async function handleVerifyCode(verificationCode: string) {
  await para.verifyNewAccount({ verificationCode });
}

// To resend the verification code:
async function handleResendCode() {
  await para.resendVerificationCode({ type: "SIGNUP" });
}
For phone number authentication, pass { phone: '+1234567890' } instead of { email }:
const result = await para.authenticateWithEmailOrPhone({
  auth: { phone: `+${countryCode}${phoneNumber}` as `+${number}` },
});

OAuth Authentication

Use para.authenticateWithOAuth() to authenticate a user via a third-party OAuth provider. The method manages the OAuth redirect/popup, polls for completion, waits for session establishment, and creates wallets for new signups.

Standard OAuth (Google, Apple, Discord, X, Facebook)

For standard OAuth providers, the onOAuthPopup callback gives you the popup window. The state listener handles biometric URLs that appear after the OAuth step completes (e.g. when a returning user needs to authenticate with their passkey).
import { para } from "./your-para-client";

let popup: Window | null = null;

async function handleOAuthLogin(method: "GOOGLE" | "APPLE" | "DISCORD" | "X" | "FACEBOOK") {
  try {
    const result = await para.authenticateWithOAuth({
      method,
      redirectCallbacks: {
        onOAuthPopup: (oauthPopup) => {
          popup = oauthPopup;
        },
      },
      oAuthPollingCallbacks: {
        onPoll: () => {
          if (popup?.closed) {
            popup = null;
          }
        },
      },
    });

    if (result.hasCreatedWallets && result.recoverySecret) {
      console.log("Recovery secret:", result.recoverySecret);
    }

    console.log("Auth info:", result.authInfo);
  } catch (error) {
    console.error("OAuth failed:", error);
  }
}

Telegram

Telegram authentication works the same way — pass "TELEGRAM" as the method:
const result = await para.authenticateWithOAuth({
  method: "TELEGRAM",
  redirectCallbacks: {
    onOAuthPopup: (popup) => {
      // The popup contains the Telegram bot interaction
    },
  },
});

Farcaster

Farcaster uses a connect URI flow. Use the redirectCallbacks.onOAuthUrl callback to receive the Farcaster Connect URI and display it as a QR code:
let farcasterUri: string | null = null;

async function handleFarcaster() {
  try {
    const result = await para.authenticateWithOAuth({
      method: "FARCASTER",
      redirectCallbacks: {
        onOAuthUrl: (url) => {
          farcasterUri = url;
          // Render this URL as a QR code for the user to scan
        },
      },
      oAuthPollingCallbacks: {
        isCanceled: () => !farcasterUri,
      },
    });

    farcasterUri = null;
    console.log("Auth info:", result.authInfo);
  } catch (error) {
    console.error("Farcaster auth failed:", error);
  }
}

Cancelling Authentication

Both methods accept polling callbacks with an isCanceled function. Return true from isCanceled to stop the polling loop — for example, when the user closes a popup or navigates away. The cancellation is clean: no error is thrown, and the optional onCancel callback is fired.
const result = await para.authenticateWithEmailOrPhone({
  auth: { email },
  sessionPollingCallbacks: {
    isCanceled: () => {
      // Cancel if the user closed the popup
      return popup === null || popup.closed;
    },
    onCancel: () => {
      console.log("User canceled authentication");
    },
  },
});
For OAuth, you can cancel both the OAuth polling and session polling independently:
const result = await para.authenticateWithOAuth({
  method: "GOOGLE",
  oAuthPollingCallbacks: {
    isCanceled: () => userClickedCancel,
    onCancel: () => console.log("OAuth polling canceled"),
  },
  sessionPollingCallbacks: {
    isCanceled: () => userClickedCancel,
    onCancel: () => console.log("Session polling canceled"),
  },
});
Calling para.logout() also cancels all active polling and resets the state phases back to unauthenticated. This is useful for implementing a “Cancel” button that fully resets the auth flow:
async function handleCancel() {
  await para.logout();
  // All polling stops, state phases reset to unauthenticated
}

Handling Results

Both methods return an AuthenticateResponse object with the same shape:
type AuthenticateResponse = {
  authInfo: CoreAuthInfo;      // The user's authentication info (email, userId, etc.)
  hasCreatedWallets: boolean;  // Whether new wallets were created during this flow
  recoverySecret?: string;     // Recovery secret for non-basic-login newly created wallets
};
FieldDescription
authInfoContains the user’s primary authentication information such as email, phone, userId, and any auth extras.
hasCreatedWalletstrue if the user is new and wallets were auto-created during signup. false for returning users.
recoverySecretPresent only for non-basic-login when new wallets are created. Basic login users do not receive a recovery secret. This should be displayed to the user or stored securely — it cannot be retrieved again.

Framework Examples

Vue 3 (Composition API)

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { para } from "./your-para-client";
import type { AuthPhase, StateSnapshot } from "@getpara/web-sdk";

const email = ref("");
const verificationCode = ref("");
const authPhase = ref<AuthPhase>("unauthenticated");
const isPending = ref(false);

let unsubscribe: (() => void) | null = null;
let popup: Window | null = null;
let lastUrl: string | null = null;

onMounted(() => {
  unsubscribe = para.onStatePhaseChange((snapshot: StateSnapshot) => {
    authPhase.value = snapshot.authPhase;
    const { authStateInfo } = snapshot;

    if (authStateInfo.verificationUrl && authStateInfo.verificationUrl !== lastUrl) {
      lastUrl = authStateInfo.verificationUrl;
      popup = window.open(authStateInfo.verificationUrl, "ParaVerification", "popup,width=400,height=500");
      return;
    }

    const url = authStateInfo.passkeyUrl || authStateInfo.passwordUrl || authStateInfo.pinUrl;
    if (url && url !== lastUrl) {
      lastUrl = url;
      popup = window.open(url, "ParaAuth", "popup,width=400,height=500");
    }
  });
});

onUnmounted(() => {
  unsubscribe?.();
  lastUrl = null;
});

async function handleAuth() {
  isPending.value = true;
  try {
    const result = await para.authenticateWithEmailOrPhone({
      auth: { email: email.value },
    });
    console.log("Authenticated:", result.authInfo);
  } catch (err) {
    console.error("Auth failed:", err);
  } finally {
    isPending.value = false;
  }
}

async function handleVerify() {
  await para.verifyNewAccount({ verificationCode: verificationCode.value });
}
</script>

Svelte

<script lang="ts">
  import { onMount, onDestroy } from "svelte";
  import { para } from "./your-para-client";
  import type { AuthPhase, StateSnapshot } from "@getpara/web-sdk";

  let email = "";
  let verificationCode = "";
  let authPhase: AuthPhase = "unauthenticated";
  let isPending = false;
  let unsubscribe: (() => void) | null = null;
  let popup: Window | null = null;
  let lastUrl: string | null = null;

  onMount(() => {
    unsubscribe = para.onStatePhaseChange((snapshot: StateSnapshot) => {
      authPhase = snapshot.authPhase;
      const { authStateInfo } = snapshot;

      if (authStateInfo.verificationUrl && authStateInfo.verificationUrl !== lastUrl) {
        lastUrl = authStateInfo.verificationUrl;
        popup = window.open(authStateInfo.verificationUrl, "ParaVerification", "popup,width=400,height=500");
        return;
      }

      const url = authStateInfo.passkeyUrl || authStateInfo.passwordUrl || authStateInfo.pinUrl;
      if (url && url !== lastUrl) {
        lastUrl = url;
        popup = window.open(url, "ParaAuth", "popup,width=400,height=500");
      }
    });
  });

  onDestroy(() => {
    unsubscribe?.();
    lastUrl = null;
  });

  async function handleAuth() {
    isPending = true;
    try {
      const result = await para.authenticateWithEmailOrPhone({
        auth: { email },
      });
      console.log("Authenticated:", result.authInfo);
    } catch (err) {
      console.error("Auth failed:", err);
    } finally {
      isPending = false;
    }
  }

  async function handleVerify() {
    await para.verifyNewAccount({ verificationCode });
  }
</script>

Next Steps