Appearance
Android SDK
The SecondFactor Kotlin file is the client half of hosted verification for Android apps written in Kotlin or Java. It opens our verification page in a Custom Tab and reads the result when the user comes back to your app. It is one file with no dependencies beyond the Android framework, for Android 7.0 (API 24) or later.
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 library 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.
Install
The library is not published to Maven. Its source is on GitHub at secondfactor/secondfactor-kotlin, and you copy its one file into your app. It is short enough to read in full, so you can also treat it as an example and adapt it.
Download
SecondFactor.ktinto your app module's source tree. Keep the packageai.secondfactor, so that updating means replacing the file. From your project's root:bashcurl --create-dirs -o app/src/main/java/ai/secondfactor/SecondFactor.kt \ https://raw.githubusercontent.com/secondfactor/secondfactor-kotlin/main/secondfactor/SecondFactor.ktMake sure your app has the
INTERNETpermission.
The version is in the file's header. To update, download the file again and read the repository's CHANGELOG.md.
Set up the return URL
The return URL must be an https:// App Link 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.
Claim the URL in your manifest, on the activity that handles the return, and give that activity
launchMode="singleTask":xml<activity android:name=".MainActivity" android:exported="true" 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 arrives inonNewIntentof the activity that is already running. Without it, Android starts a second copy of your activity on top of the tab, and Back leads into the finished verification page.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.openVerification. We send the code, the user types it on our page, and we send them back to your return URL, which Android hands to your app as an App Link. - Your activity calls
SecondFactor.takeVerificationReturnon the intent it receives and posts thereturnTokento your server. - Your server calls
verifySessionwith thesidit stored and that return token, once, and acts on the answer.
Usage
A complete activity, from asking your server for the page to telling the user how it went. It uses Jetpack Compose and coroutines, which most apps already have; the library itself needs neither. MyApi stands for your app's own client for your server, with one endpoint for step 1 and one for step 3.
kotlin
import ai.secondfactor.SecondFactor
import ai.secondfactor.VerificationReturn
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
/** Your app's client for your own server. */
interface MyApi {
/** Asks your server to call `createSession`, and returns the page's `url`. */
suspend fun startVerification(): String
/** Hands the return token to your server, which calls `verifySession`. */
suspend fun finishVerification(returnToken: String)
}
/**
* Opens the hosted page and shows the result. In the manifest, this activity
* claims the return URL and has `launchMode="singleTask"`, as described under
* "Set up the return URL".
*/
class VerifyPhoneActivity : ComponentActivity() {
/** Provided by your app, for example through dependency injection. */
lateinit var api: MyApi
private var busy by mutableStateOf(false)
private var message by mutableStateOf("")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Column {
Button(onClick = ::startVerification, enabled = !busy) {
Text("Verify phone number")
}
Text(message)
}
}
// A fresh launch can itself be the return. Android may end the app's
// process while the page is open, and the return URL then starts the
// app again. A recreated activity has already read its intent.
if (savedInstanceState == null) handleReturn(SecondFactor.takeVerificationReturn(intent))
}
// With singleTask, the return reaches the running activity here.
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleReturn(SecondFactor.takeVerificationReturn(intent))
}
private fun startVerification() {
busy = true
lifecycleScope.launch {
try {
SecondFactor.openVerification(this@VerifyPhoneActivity, api.startVerification())
} catch (e: ActivityNotFoundException) {
message = "There is no browser to open the verification page in."
} finally {
// An IllegalArgumentException from openVerification is not
// caught: it means the server sent something other than the
// page's url, which is a bug to fix, not a user error.
busy = false
}
}
}
private fun handleReturn(back: VerificationReturn?) {
back ?: return // Not a return from the hosted page.
val token = back.returnToken
if (token == null) {
// The user canceled on the page, ran out of attempts, or the
// session expired.
message = "Your number was not verified. Please try again."
return
}
lifecycleScope.launch {
api.finishVerification(token)
message = "Your number is verified."
}
}
}If the user closes the Custom Tab instead of finishing, nothing comes back: the activity simply resumes, and the user can press the button again.
SecondFactor.openVerification
kotlin
fun openVerification(context: Context, url: String)Opens the hosted page in a Custom Tab, or in the default browser when no installed browser supports Custom Tabs. Pass an Activity as context, so that Back returns to your app.
| Param | Type | Description |
|---|---|---|
context | Context | The activity that starts verification. |
url | String | The url your server got when it created the session. |
Throws IllegalArgumentException, without opening anything, when url is not a verification page, such as a javascript: or intent: URL. Throws ActivityNotFoundException when the device has no browser at all.
SecondFactor.takeVerificationReturn
kotlin
fun takeVerificationReturn(intent: Intent?): VerificationReturn?Reads sf_session_id and sf_return_token from the intent your activity was opened with, and removes the URL from the intent so it is not read twice. Returns null when the intent did not come from the hosted page, for example a launch from the home screen. Call it from onCreate when savedInstanceState is null, and from onNewIntent.
SecondFactor.parseVerificationReturn
kotlin
fun parseVerificationReturn(url: String?): VerificationReturn?The same as takeVerificationReturn, for any URL string, without touching an intent.
SecondFactor.isVerificationPageUrl
kotlin
fun isVerificationPageUrl(url: String): BooleanReturns 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. openVerification checks this before it opens anything.
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.
Demo app
The repository's demo/ is a small app with a two-step sign-in (a password, then the phone number verified on the hosted page), plus the server it talks to. It runs on an emulator against a stand-in for the hosted page, with no account needed; the repository's README explains how to start it.

