Skip to content

React Native SDK ​

The @secondfactor/react-native module is the client half of hosted verification for React Native apps on iOS and Android. It opens our verification page and returns the result when the user comes back to your app.

  • iOS: the page opens in an ASWebAuthenticationSession sheet that closes by itself on return.
  • Android: the page opens in a Custom Tab, and the return arrives as an App Link.

It is a TurboModule with no dependencies beyond React Native.

It runs on the user's device. It never calls the secondfactor.ai API, and never holds your API key, a phone number or a code. Your server creates and confirms the session with the Node.js or Python SDK.

Act on the result on your server. What this module reads from a URL is not proof of anything, because anyone can craft a link that opens your app. Your server's verifySession call is the proof.

If you would rather open the page in the system browser yourself, the browser SDK works in React Native too.

Requirements ​

  • React Native 0.77 or later, with the New Architecture.
  • iOS 17.4 or later for verify, which uses ASWebAuthenticationSession with an https:// callback. The module builds for React Native's own minimum, where verify throws couldNotStart.
  • Android 7.0 (API 24) or later.
  • Expo: works in a development build (npx expo prebuild or EAS Build). Expo Go cannot load it.

Install ​

The module is not published to npm. Its source is on GitHub at secondfactor/secondfactor-react-native: clone it, build it, and install the result in your app. It is short enough to read in full, so you can also treat it as an example and adapt it.

bash
# Build the module from a clone of the repository.
git clone https://github.com/secondfactor/secondfactor-react-native.git
cd secondfactor-react-native
npm install
npm run build
npm pack        # writes secondfactor-react-native-0.1.0.tgz

# Install it in your app, keeping the file in your app's repository.
cd ../your-app
mkdir -p vendor
cp ../secondfactor-react-native/secondfactor-react-native-0.1.0.tgz vendor/
npm install ./vendor/secondfactor-react-native-0.1.0.tgz
cd ios && pod install

Then rebuild the app. Autolinking registers the module on both platforms. Keeping the .tgz file in your repository means every checkout and CI build installs the same code. To update, pull the clone, repeat the build, and replace the file.

Set up ​

The return URL must be an https:// URL on one of the return origins listed under Settings → Hosted verification in the dashboard. Custom URL schemes such as myapp:// are not accepted, because any app can claim them. The examples use https://app.example.com/verified.

iOS ​

  1. Give your app the Associated Domains capability with webcredentials:app.example.com. With Expo, add it under ios.associatedDomains in app.json.

  2. Serve https://app.example.com/.well-known/apple-app-site-association listing your app under webcredentials:

    json
    { "webcredentials": { "apps": ["TEAMID.com.example.yourapp"] } }

Without both, iOS does not hand the return back to your app, and the sheet does not close by itself.

Android ​

  1. Claim the URL in android/app/src/main/AndroidManifest.xml, on your main activity. Keep its launchMode as singleTask, the React Native template's default:

    xml
    <activity
        android:name=".MainActivity"
        android:launchMode="singleTask"
        ...>
        <intent-filter android:autoVerify="true">
            <action android:name="android.intent.action.VIEW" />
            <category android:name="android.intent.category.DEFAULT" />
            <category android:name="android.intent.category.BROWSABLE" />
            <data android:scheme="https" android:host="app.example.com" android:path="/verified" />
        </intent-filter>
    </activity>

    With Expo, add the filter under android.intentFilters in app.json.

    singleTask matters. The Custom Tab runs in your app's task, on top of your activity. With singleTask, the return closes the tab and reaches the running activity. With singleTop or standard, Android starts a second copy of your app on top of the tab, and verify never returns.

  2. Prove you own the domain by serving https://app.example.com/.well-known/assetlinks.json:

    json
    [{
      "relation": ["delegate_permission/common.handle_all_urls"],
      "target": {
        "namespace": "android_app",
        "package_name": "com.example.app",
        "sha256_cert_fingerprints": ["AB:CD:…"]
      }
    }]

    Use the SHA-256 fingerprint of the key your release builds are signed with (Play Console → Test and release → App signing), and add your debug key's fingerprint too if you want links to work in debug builds. Until the domain is verified, Android opens the return URL in the browser instead of your app. Check the state with adb shell pm get-app-links com.example.app.

On Android, React Native's Linking also reports the return URL as a url event. If your navigation library handles deep links, such as React Navigation's linking option, leave the return path out of its configuration, or it will navigate to it as well.

How it fits together ​

  1. Your server creates a session, stores its sid and gives the page's url to your app.
  2. Your app calls verify(url, returnUrl). We send the code, the user types it on our page, and we send them back to your return URL.
  3. Your app posts the returnToken that verify resolved with to your server.
  4. Your server calls verifySession with the sid it stored and that return token, once, and acts on the answer.

Usage ​

A complete verification, from asking your server for the page to telling the user how it went. MyApi stands for your app's own client for your server, with one endpoint for step 1 and one for step 3.

tsx
import { useState } from "react";
import { Button, Text, View } from "react-native";
import { SecondFactorError, verify } from "@secondfactor/react-native";

/** Your app's client for your own server. */
export interface MyApi {
  /** Asks your server to call `createSession`, and returns the page's `url`. */
  startVerification(): Promise<string>;
  /** Hands the return token to your server, which calls `verifySession`. */
  finishVerification(returnToken: string): Promise<void>;
}

/** The same `https://` URL your server passes to `createSession`. */
const RETURN_URL = "https://app.example.com/verified";

/** Runs one verification and returns a message to show the user. */
export async function verifyPhone(api: MyApi): Promise<string> {
  const url = await api.startVerification();

  let returnToken: string | null;
  try {
    ({ returnToken } = await verify(url, RETURN_URL));
  } catch (error) {
    if (error instanceof SecondFactorError) {
      switch (error.code) {
        case "canceled":
          return "Verification canceled.";
        case "couldNotStart":
          return "The verification page could not be opened.";
      }
    }
    // Anything else means the app and the server disagree about a URL, or the
    // platform failed. That is a bug to fix, not something the user can act
    // on, so let it reach your error reporting.
    throw error;
  }

  if (!returnToken) {
    // The user ran out of attempts, or the session expired.
    return "Your number was not verified. Please try again.";
  }
  await api.finishVerification(returnToken);
  return "Your number is verified.";
}

/** A button that runs `verifyPhone` and shows the result. */
export function VerifyPhoneButton({ api }: { api: MyApi }) {
  const [busy, setBusy] = useState(false);
  const [message, setMessage] = useState("");

  async function onPress() {
    setBusy(true);
    try {
      setMessage(await verifyPhone(api));
    } catch (error) {
      console.error(error);
      setMessage("Something went wrong. Please try again.");
    } finally {
      setBusy(false);
    }
  }

  return (
    <View>
      <Button title="Verify phone number" onPress={onPress} disabled={busy} />
      <Text>{message}</Text>
    </View>
  );
}

The button is disabled while a verification is open. Starting a second one would end the first as canceled.

After Android ends your app ​

Android may end your app's process while the page is open, most often on low-memory devices. The user still finishes on the page, and the return URL then starts your app afresh. The verify call that was waiting is gone, so pick the return up at launch instead:

tsx
import { useEffect } from "react";
import { Linking } from "react-native";
import { parseVerificationReturn } from "@secondfactor/react-native";

export function useVerificationReturnAtLaunch(api: MyApi) {
  useEffect(() => {
    Linking.getInitialURL().then((url) => {
      const back = parseVerificationReturn(url);
      if (back?.returnToken) {
        // Your server still confirms the session it stored, never one named
        // in this URL.
        api.finishVerification(back.returnToken).catch(console.error);
      }
    });
  }, [api]);
}

verify(url, returnUrl) ​

Opens the hosted page and resolves with a VerificationReturn when the page sends the user to returnUrl.

ParamTypeDescription
urlstringThe url your server got when it created the session. Anything else throws notAVerificationPage and is not opened.
returnUrlstringThe same https:// URL your server passed as ReturnUrl. Only its host and path are matched.

Starting a second verification while one is open ends the first as canceled. On iOS the sheet uses an ephemeral browser session, so it shares no cookies with Safari and keeps none afterwards.

parseVerificationReturn(url) ​

Reads sf_session_id and sf_return_token from any URL string, or returns null when there is no session ID, including when url is null or undefined. verify calls it for you. Call it yourself when the return reaches your app another way, as in After Android ends your app.

isVerificationPageUrl(url) ​

Returns whether url has the shape of a hosted page: https:// (plain http:// is accepted only for localhost, 127.0.0.1, [::1] and *.localhost), no user name or password, and the session's token in its fragment.

VerificationReturn ​

FieldTypeDescription
sessionIdstringsf_session_id. Which stored session the user came back from. Always confirm the sid your server stored, never this one.
returnTokenstring or nullsf_return_token. Present only when the user verified; null when they canceled on the page, ran out of attempts or the session expired. It works once.

Errors ​

verify rejects with a SecondFactorError whose code is one of:

CodeMeaning
notAVerificationPageurl is not a hosted page. Pass the server's url unchanged.
invalidReturnUrlreturnUrl is not https:// with a host.
canceledThe user closed the page. Nothing was verified.
missingReturnParametersThe page returned without sf_session_id.
couldNotStartNo activity, window or browser to show the page, iOS older than 17.4, or the native module is missing from the build.

Any other native error is thrown unchanged.