Appearance
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
ASWebAuthenticationSessionsheet 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 usesASWebAuthenticationSessionwith anhttps://callback. The module builds for React Native's own minimum, whereverifythrowscouldNotStart. - Android 7.0 (API 24) or later.
- Expo: works in a development build (
npx expo prebuildor 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 installThen 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
Give your app the Associated Domains capability with
webcredentials:app.example.com. With Expo, add it underios.associatedDomainsinapp.json.Serve
https://app.example.com/.well-known/apple-app-site-associationlisting your app underwebcredentials: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
Claim the URL in
android/app/src/main/AndroidManifest.xml, on your main activity. Keep itslaunchModeassingleTask, 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.intentFiltersinapp.json.singleTaskmatters. The Custom Tab runs in your app's task, on top of your activity. WithsingleTask, the return closes the tab and reaches the running activity. WithsingleToporstandard, Android starts a second copy of your app on top of the tab, andverifynever returns.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
- Your server creates a session, stores its
sidand gives the page'surlto your app. - 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. - Your app posts the
returnTokenthatverifyresolved with to your server. - Your server calls
verifySessionwith thesidit 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.
| Param | Type | Description |
|---|---|---|
url | string | The url your server got when it created the session. Anything else throws notAVerificationPage and is not opened. |
returnUrl | string | The 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
| Field | Type | Description |
|---|---|---|
sessionId | string | sf_session_id. Which stored session the user came back from. Always confirm the sid your server stored, never this one. |
returnToken | string or null | sf_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:
| Code | Meaning |
|---|---|
notAVerificationPage | url is not a hosted page. Pass the server's url unchanged. |
invalidReturnUrl | returnUrl is not https:// with a host. |
canceled | The user closed the page. Nothing was verified. |
missingReturnParameters | The page returned without sf_session_id. |
couldNotStart | No 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.

