Skip to content

Flutter SDK ​

The secondfactor Flutter plugin is the client half of hosted verification for Flutter 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 has no dependencies beyond Flutter.

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

Requirements ​

  • Flutter 3.10 or later.
  • iOS 17.4 or later for SecondFactor.verify, which uses ASWebAuthenticationSession with an https:// callback. The plugin builds for older versions, where verify throws couldNotStart.
  • Android 7.0 (API 24) or later.

Install ​

The plugin is not published to pub.dev. Its source is on GitHub at secondfactor/secondfactor-flutter: clone it next to your app and depend on it by path. It is short enough to read in full, so you can also treat it as an example and adapt it.

bash
# Clone into a directory named `secondfactor`, next to your app.
git clone https://github.com/secondfactor/secondfactor-flutter.git secondfactor

Then add it to your app's pubspec.yaml and run flutter pub get:

yaml
dependencies:
  secondfactor:
    path: ../secondfactor

Clone into a directory named secondfactor, the plugin's own name. Flutter's Swift Package Manager integration names the plugin after its directory, and Xcode refuses to resolve the package when that name differs. To update, pull the clone and run flutter pub get again.

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.

  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 Flutter activity, and change its launchMode from singleTop to singleTask:

    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>

    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 Flutter's default singleTop, 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.

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 SecondFactor.verify. 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 completed 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.

dart
import 'package:flutter/material.dart';
import 'package:secondfactor/secondfactor.dart';

/// Your app's client for your own server.
abstract interface class MyApi {
  /// Asks your server to call `createSession`, and returns the page's `url`.
  Future<String> startVerification();

  /// Hands the return token to your server, which calls `verifySession`.
  Future<void> finishVerification(String returnToken);
}

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

/// Runs one verification and returns a message to show the user.
Future<String> verifyPhone(MyApi api) async {
  final url = await api.startVerification();

  final VerificationReturn back;
  try {
    back = await SecondFactor.verify(url: url, returnUrl: returnUrl);
  } on SecondFactorException catch (e) {
    switch (e.error) {
      case SecondFactorError.canceled:
        return 'Verification canceled.';
      case SecondFactorError.couldNotStart:
        return 'The verification page could not be opened.';
      case SecondFactorError.notAVerificationPage:
      case SecondFactorError.invalidReturnUrl:
      case SecondFactorError.missingReturnParameters:
        // The app and the server disagree about a URL. That is a bug to
        // fix, not something the user can act on, so let it reach your
        // error reporting.
        rethrow;
    }
  }

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

/// A button that runs [verifyPhone] and shows the result.
class VerifyPhoneButton extends StatefulWidget {
  const VerifyPhoneButton({super.key, required this.api});

  final MyApi api;

  @override
  State<VerifyPhoneButton> createState() => _VerifyPhoneButtonState();
}

class _VerifyPhoneButtonState extends State<VerifyPhoneButton> {
  bool _busy = false;

  Future<void> _verify() async {
    setState(() => _busy = true);
    try {
      final message = await verifyPhone(widget.api);
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
    } finally {
      if (mounted) setState(() => _busy = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return FilledButton(
      onPressed: _busy ? null : _verify,
      child: const Text('Verify phone number'),
    );
  }
}

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

SecondFactor.verify ​

dart
Future<VerificationReturn> verify({required String url, required String returnUrl})

Opens the hosted page and completes 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.

SecondFactor.parseVerificationReturn ​

dart
VerificationReturn? parseVerificationReturn(String? url)

Reads sf_session_id and sf_return_token from any URL, or returns null when there is no session ID. verify calls it for you. Call it yourself when the return reaches your app another way. On Android this happens when the system ends your app's process while the page is open: the app then starts afresh with the return URL as its deep link, and the pending verify call no longer exists.

SecondFactor.isVerificationPageUrl ​

dart
bool isVerificationPageUrl(String 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?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.

Its toString() leaves out the return token, so logging it never leaks a token that can still be redeemed.

Errors ​

verify throws a SecondFactorException whose error is one of these SecondFactorError values:

ErrorMeaning
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, or iOS older than 17.4.

Any other platform error is thrown unchanged, as a PlatformException.

Example app ​

The repository's example/ directory is a one-screen app that calls verify with a page URL you paste in.