Appearance
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
ASWebAuthenticationSessionsheet 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 usesASWebAuthenticationSessionwith anhttps://callback. The plugin builds for older versions, whereverifythrowscouldNotStart. - 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 secondfactorThen add it to your app's pubspec.yaml and run flutter pub get:
yaml
dependencies:
secondfactor:
path: ../secondfactorClone 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
Give your app the Associated Domains capability with
webcredentials:app.example.com.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 Flutter activity, and change itslaunchModefromsingleToptosingleTask: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>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. With Flutter's defaultsingleTop, 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.
How it fits together
- Your server creates a session, stores its
sidand gives the page'surlto your app. - 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. - Your app posts the
returnTokenthatverifycompleted 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.
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.
| 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.
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
| 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? | 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:
| Error | 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, 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.

