How to Test Two-Factor Authentication on Android (Complete Guide)
Two‑factor authentication (2FA) is now a baseline security control for most Android applications that handle personal data, financial transactions, or enterprise credentials. When 2FA fails, attackers
Motivation
Two‑factor authentication (2FA) is now a baseline security control for most Android applications that handle personal data, financial transactions, or enterprise credentials. When 2FA fails, attackers can bypass the second layer and gain unauthorized access, while legitimate users may be locked out, leading to support spikes and churn. Testing 2FA is therefore not a niche activity; it is a core part of release validation because the flow touches multiple subsystems: UI, network, cryptography, background services, and inter‑app communication (e.g., authenticator apps or SMS retriever). A defect in any of these areas can manifest as a crash, an ANR, a silent fallback to password‑only login, or a leaked secret. The goal of this guide is to give you a repeatable, thorough method to uncover those defects before they reach users.
Test Matrix
Below is a comprehensive matrix that you can use as a checklist for each 2FA implementation you encounter. Rows represent test categories; columns represent the verdict you should record (PASS, FAIL, NA). Each cell expects a concrete observation (e.g., “OTP field accepts 6‑digit numeric input”, “Error toast shows after three wrong codes”).
| Category | Sub‑case | Expected behavior | PASS | FAIL | NA | ||
|---|---|---|---|---|---|---|---|
| Happy path | Correct OTP entered within validity period | Login succeeds, session token issued, user lands on home screen | |||||
| OTP retrieved via SMS Retriever API | Auto‑fill works, no manual entry needed | ||||||
| OTP from third‑party authenticator (TOTP) | Manual entry works, QR code scanned correctly | ||||||
| Error paths | Wrong OTP (incorrect) OTP entered, error toast/s | ||||||
| Expired OTP entered | Error message “Code expired” | ||||||
| Network OTP request times out | Error message displayed, retry allowed, lockout after configurable attempts | ||||||
| OTP too short/long | Input rejected immediately, validation feedback shown | ||||||
| Non‑numeric characters in numeric OTP field | Field rejects or sanitizes, no crash | ||||||
| Empty OTP submission | Validation prevents submission, focus remains on field | ||||||
| OTP request rate‑limited | UI shows “Too many attempts, try again later” and disables resend button | ||||||
| Edge cases | Device time changed manually | App detects time drift, either rejects OTP or shows warning | |||||
| Airplane mode toggled during OTP wait | App handles lack of network gracefully, retains OTP state | ||||||
| App killed while waiting for OTP | On relaunch, OTP request is re‑initiated or user sees appropriate message | ||||||
| OTP received via push notification while app in background | Notification tap opens app and pre‑fills OTP | ||||||
| Biometric fallback offered after OTP failure | Biometric prompt appears, succeeds if enrolled | ||||||
| Accessibility | TalkBack navigation | All OTP fields, resend button, and error messages are reachable and announced | |||||
| Dynamic font scaling | Layout does not break at 200% font size | ||||||
| Color contrast | Error text meets WCAG AA contrast ratio | ||||||
| Switch Control | OTP can be entered via switch scanning without missing characters | ||||||
| Security / Privacy | OTP leaked in logs | No OTP appears in Logcat or crash reports | |||||
| OTP stored in plain text | OTP never persisted to disk or SharedPreferences | ||||||
| Replay attack | Reusing a previously valid OTP is rejected | ||||||
| Brute‑force protection | After N failed attempts, OTP entry is temporarily disabled | ||||||
| Secret key exposure | QR code or secret never appears in screenshots or accessibility buffer | ||||||
| Interoperability | SMS Retriever API hash mismatch | App fails gracefully, falls back to manual entry | |||||
| Authenticator app not installed | App shows clear instruction to install an authenticator | ||||||
| Multiple OTP channels (SMS + email) | User can choose channel, each works independently |
You can copy this table into a spreadsheet or test management tool and fill in the PASS/FAIL/NA columns for each build. The matrix forces you to verify not only the nominal flow but also the failure modes that often surface only under stress or unusual device states.
Manual Testing Approach
A disciplined manual session starts with a clean device state (no leftover accounts, cleared cache, and factory‑reset time). Follow these steps for each 2FA variant you need to validate:
- Preparation
- Install the target APK via
adb install -r app.apk. - Clear app data:
adb shell pm clear com.example.app. - Sync device time with an NTP server:
adb shell date $(date +%m%d%H%M%Y.%S). - Enable developer options and USB debugging.
- Baseline login
- Launch the app, navigate to the login screen, and enter a valid username/password.
- Observe the transition to the 2FA screen. Record UI latency and any loading indicators.
- SMS‑based OTP
- Use a test SIM or a service like Twilio to send a known OTP to the device.
- Verify that the SMS Retriever API triggers auto‑fill: the OTP field should populate without user interaction.
- If auto‑fill fails, manually enter the OTP and confirm login succeeds.
- Authenticator‑app OTP (TOTP)
- Scan the QR code displayed by the app with Google Authenticator, Authy, or a similar tool.
- Enter the six‑digit code generated by the authenticator.
- Change the device time by ±2 minutes and verify that the OTP is rejected (time‑window check).
- Error injection
- Enter an incorrect OTP three times; ensure the app shows an error after each attempt and disables further entry after the configured lockout threshold.
- Attempt to paste a non‑numeric string into a numeric OTP field; confirm the input is rejected or filtered.
- Leave the OTP field empty and tap “Submit”; verify that submission is blocked and an inline error appears.
- Network interruptions
- Turn on airplane mode after the OTP request is sent but before the code arrives.
- Observe whether the app shows a timeout message, retains the OTP state, and allows a retry once connectivity returns.
- Repeat with Wi‑Fi only, then with mobile data only, to confirm graceful degradation.
- Background and lifecycle
- Send an OTP via push notification while the app is in the background. Tap the notification and confirm the app opens directly to the OTP screen with the code pre‑filled.
- Kill the app from recent‑apps while waiting for OTP, then relaunch; verify that the app either restarts the OTP request or shows a clear “Session expired” message.
- Accessibility checks
- Enable TalkBack, navigate to each element using swipe gestures, and ensure spoken labels match visual text.
- Increase font size to 200% in Settings → Accessibility → Font size; verify that no UI elements are clipped or overlapped.
- Run the Color Contrast Analyzer overlay (available as an Android accessibility service) to confirm AA compliance on error messages and buttons.
- Security sniffing
- Connect the device to a workstation running
adb logcat -v threadtimeand attempt to log in. Search the output for the OTP string; it should not appear. - Use
adb shell run-as com.example.app cat /data/data/com.example.app/shared_prefs/*.xmlto verify that no OTP or secret is stored in plain text.
- Post‑login verification
- After a successful 2FA login, check that the app issues a short‑lived access token and a refresh token stored in the Android Keystore or EncryptedSharedPreferences.
- Log out and ensure that the OTP screen does not re‑appear without re‑entering credentials.
Document each observation in the test matrix. If any step fails, create a bug report that includes the exact device model, Android version, app version, and logs captured via adb bugreport.
Automated Approaches and Tooling Specific to Android
Manual testing is essential for exploratory work, but regression safety requires automation. Below are the most effective strategies for Android 2FA testing, ranging from unit‑level to UI‑level.
Unit‑level validation
- ViewModel / UseCase tests – Inject a fake
OtpRepositorythat returns predefined OTPs or errors. Verify that the ViewModel transitions to the correct state (loading, success, error). - Network layer – Use MockWebServer to simulate SMS Retriever responses, incorrect OTP responses, and rate‑limit headers. Assert that the app handles each HTTP status code appropriately.
Instrumented UI tests (Espresso)
Espresso runs on the device or emulator and can interact with native UI components. The following snippet demonstrates a happy‑path test for SMS Retriever‑based OTP:
@RunWith(AndroidJUnit4::class)
class OtpLoginTest {
@get:Rule
val activityRule = ActivityScenarioRule(LoginActivity::class.java)
@Test
fun `sms retriever auto-fills and logs in`() {
// Trigger OTP request
onView(withId(R.id.btnRequestOtp)).perform(click())
// Simulate SMS Retriever broadcast
val intent = Intent(SmsRetriever.SMS_RETRIEVED_ACTION)
intent.putExtra(SmsRetriever.EXTRA_SMS_MESSAGE, "<#> Your OTP is 123456 ABCDEF")
InstrumentationRegistry.getInstrumentation()
.targetContext
.sendBroadcast(intent)
// Verify OTP field filled
onView(withId(R.id.etOtp)).check(matches(withText("123456")))
// Submit
onView(withId(R.id.btnSubmitOtp)).perform(click())
// Assert landing on home screen
onView(withId(R.id.nav_home)).check(matches(isDisplayed()))
}
}
Key points:
- The test uses
InstrumentationRegistryto send a fake SMS Retriever broadcast, avoiding reliance on a real telephony stack. - Assertions are limited to UI state; you can also extract the session token from a mock
AuthRepositoryif you expose it via a test interface.
Handling TOTP in Espresso
Testing TOTP requires generating a time‑based code within the test. The following utility computes a valid OTP given a shared secret:
fun generateTotp(secretBase32: String, timeStep: Long = 30): String {
val crypto = Mac.getInstance("HmacSHA1")
val key = Base32.decode(secretBase32)
crypto.init(SecretKeySpec(key, "HmacSHA1"))
val time = System.currentTimeMillis() / 1000 / timeStep
val data = ByteBuffer.allocate(8).putLong(time.reversed()).array()
val hash = crypto.doFinal(data)
val offset = hash[hash.lastIndex] and 0x0f
val truncated = ((hash[offset] and 0x7f) shl 24) or
((hash[offset + 1] and 0xff) shl 16) or
((hash[offset + 2] and 0xff) shl 8) or
(hash[offset + 3] and 0xff)
val otp = truncated % 1000000
return String.format("%06d", otp)
}
You can then call this function in your test, entering the result into the OTP field.
UI Automator for cross‑app flows
When 2FA involves switching to an authenticator app or handling a push notification, UI Automator is better suited because it can interact with any app on the device. Example: launching Google Authenticator, retrieving the code, and returning to the SUT.
@Test
public void totpViaAuthenticatorApp() throws UiObjectNotFoundException {
// Assume we are on the OTP entry screen of the SUT
UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
// Tap the “Scan QR” button which launches Authenticator
UiObject scanBtn = device.findObject(new UiSelector().resourceId("com.example.app:id/btnScanQr"));
scanBtn.clickAndWaitForNewWindow();
// In Authenticator, find the account label and extract the code
UiObject account = device.findObject(new UiSelector()
.textContains("example@example.com")
.className("android.widget.TextView"));
String otp = account.getText(); // assumes the app shows the code next to the label
// Return to SUT (press back)
device.pressBack();
// Enter OTP and submit
UiObject otpField = device.findObject(new UiSelector().resourceId("com.example.app:id/etOtp"));
otpField.setText(otp);
UiObject submit = device.findObject(new UiSelector().resourceId("com.example.app:id/btnSubmitOtp"));
submit.click();
// Verify home screen
UiObject home = device.findObject(new UiSelector().resourceId("com.example.app:id/nav_home"));
assertTrue(home.waitForExists(5000));
}
Appium for cross‑platform or hybrid apps
If your Android app uses a WebView for the 2FA screen (common in hybrid frameworks), Appium lets you switch contexts:
AndroidDriver<MobileElement> driver = new AndroidDriver<>(new URL("http://localhost:4723/wd/hub"), caps);
// ... navigate to 2FA screen
Set<String> contexts = driver.getContextHandles();
for (String ctx : contexts) {
if (ctx.contains("WEBVIEW")) {
driver.context(ctx);
WebElement otpInput = driver.findElement(By.id("otp-input"));
otpInput.sendKeys("123456");
driver.findElement(By.id("submit-btn")).click();
driver.context("NATIVE_APP");
break;
}
}
Automated security checks
- MobSF (Mobile Security Framework) can scan the APK for hardcoded secrets, insecure logging, and improper use of the Android Keystore.
- Drozer can attempt to exploit exported components that might leak the OTP request intent.
Continuous integration integration
Add the Espresso/UI Automator tests to your CI pipeline (GitHub Actions, GitLab CI, or Jenkins). Use Firebase Test Lab to run the instrumentation tests on a matrix of devices (different API levels, screen sizes, and locales). Example GitHub Actions snippet:
name: Android UI Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 30
target: google_apis
arch: x86_64
- name: Run Espresso tests
run: ./gradlew connectedAndroidTest
This setup guarantees that every commit is exercised against a fresh emulator with the same 2FA logic you validated manually.
Code Snippets and Commands You’ll Use Frequently
| Purpose | Command / Snippet | Notes | ||
|---|---|---|---|---|
| Install APK and clear data | adb install -r app.apkadb shell pm clear com.example.app | Guarantees a clean state. | ||
| Simulate SMS Retriever broadcast | adb shell am broadcast -a com.google.android.gms.auth.api.phone.SMS_RETRIEVED --es "com.google.android.gms.auth.api.phone.SMS_RETRIEVED_EXTRA" "<#> Your OTP is 654321 ABCD" | Replace the OTP as needed. | ||
| Pull logs for OTP leakage | `adb logcat -v threadtime | grep -i "otp\ | code"` | Should return no matches. |
| Check shared preferences for plain OTP | adb shell run-as com.example.app cat /data/data/com.example.app/shared_prefs/*.xml | Look for any containing digits. | ||
| Force time change (requires root) | adb shell date 031512002023.00 | Sets date to Mar 15 2023 12:00:00. Use only on test devices. | ||
| Trigger push notification via FCM (test token) | curl -X POST -H "Authorization: key=YOUR_SERVER_KEY" -H "Content-Type: application/json" -d '{"to":"DEVICE_TOKEN","notification":{"title":"OTP","body":"Your code is 789012"}}' https://fcm.googleapis.com/fcm/send | Useful for background‑notification scenarios. | ||
| Run all instrumented tests on Firebase Test Lab | gcloud firebase test android run --type instrumentation --app app-debug.apk --test app-debug-test.apk --device model=Pixel3,version=29,locale=en,orientation=portrait | Adjust model/version as needed. | ||
| Generate TOTP in Bash (for manual verification) | oathtool --totp -b SECRETBASE32 | Replace SECRETBASE32 with the authenticator secret. |
Edge Cases That Only Appear in Production
Even with exhaustive matrix coverage, certain failure modes surface only when the app runs under real‑world conditions. Below are the most common production‑only 2FA bugs and how to detect them.
1. Carrier‑specific SMS filtering
Some mobile carriers (especially in regions with strict spam filters) delay or block SMS messages that contain certain patterns (e.g., “<#>”). If your app relies on the SMS Retriever API, the broadcast may never fire, leaving the user staring at a blank OTP field.
Detection:
- Use a SIM from a different carrier or a virtual number service (e.g., Twilio) that mimics carrier filtering.
- Monitor
LogcatforSmsRetrievererrors:SMS Retriever API failed: SMS_RETRIEVER_TIMEOUT.
2. Background execution limits on Android 12+
Starting with Android 12, background services face stricter start‑foreground limits. If your app starts a foreground service to listen for SMS Retriever intents while in the background, the system may kill it, causing OTP auto‑fill to fail after the app has been idle for a few minutes.
Detection:
- Put the app in the background, wait >10 minutes, then send an OTP via SMS.
- Verify that the OTP field does not auto‑fill.
- Check
adb shell dumpsys activity servicesfor the service state.
3. Authenticator app time‑sync drift
Users may have their device clock off by several minutes due to manual time zone changes or poor NTP sync. Most authenticator apps tolerate a 30‑second window, but some implementations enforce a stricter window (e.g., 15 seconds). If your server validates with a larger window than the client, you’ll see intermittent “invalid OTP” reports from users whose clocks are off.
Detection:
- Manually set the device clock ahead by 4 minutes, attempt a TOTP login, and observe the failure.
- Adjust server‑side validation window to match the authenticator app’s tolerance (typically ±1 step).
4. Notification channel importance changes
If your app sends OTPs via push notification and the user has set the notification channel to “Importance: Low”, the heads‑up display may be suppressed, causing the user to miss the OTP.
Detection:
- Change the notification channel importance via Settings → Apps → Your App → Notifications → OTP channel → Importance.
- Send a test push and verify whether a heads‑up appears.
5. Biometric fallback race condition
Some apps allow biometric authentication after a failed OTP attempt. On certain devices, the biometric prompt can appear *before* the OTP error toast disappears, leading to overlapping UI and missed taps.
Detection:
- Trigger a bad OTP, then quickly place a finger on the sensor.
- Observe whether both prompts are visible simultaneously and whether input is routed correctly.
6. Data‑saver mode stripping network payloads
When Android’s Data Saver is active, some apps downgrade network requests to reduce bandwidth. If your OTP verification endpoint relies on a POST with a JSON body, the body may be stripped, resulting in a 400 error that the app treats as a network failure rather than an invalid OTP.
Detection:
- Enable Data Saver, attempt a valid OTP login, and inspect the network traffic with
adb shell cmd netpolicy set restrict-background com.example.appor usingHttpCanary. - Ensure the app shows a clear “Invalid OTP” message rather than a generic network error.
7. Shared user ID (UID) collisions in enterprise environments
In managed Android Enterprise profiles, multiple apps may share the same UID for sandboxing. If your app writes the OTP to a file using MODE_WORLD_READABLE (deprecated but still present in legacy code), a co‑resident app could read it.
Detection:
- Use
adb shell run-as com.example.app ls -l /data/data/com.example.app/files/to verify file permissions arerw-------. - Search the source for
openFileOutput(..., Context.MODE_WORLD_READABLE).
Addressing these issues often requires server‑side adjustments (wider time windows, idempotent OTP verification) or client‑side guards (checking notification importance, respecting background limits, using EncryptedSharedPreferences). Document any production‑only findings in a separate “Post‑release watchlist” so they can be regressed in future cycles.
Short Checklist for Release Sign‑off
Before you promote a build to production, run through this concise list. Mark each item as YES (verified) or NO (blocking).
- [ ] Happy‑path login succeeds with SMS Retriever auto‑fill on at least two different Android versions (e.g., 11 and 13).
- [ ] Wrong OTP triggers appropriate error and lockout after configured attempts.
- [ ] Empty or non‑numeric OTP input is rejected without crash.
- [ ] App handles airplane‑mode toggles during OTP wait gracefully.
- [ ] OTP never appears in Logcat, crash dumps, or shared preferences (verified via
adb logcatandrun-as). - [ ] TalkBack can reach and announce every OTP‑related element.
- [ ] Font scaling to 200% does not clip OTP field or buttons.
- [ ] Error messages meet WCAG AA contrast (checked with a contrast analyzer).
- [ ] Notification channel importance is set to at least “High” for OTP pushes.
- [ ] Biometric fallback does not overlap with OTP error toast.
- [ ] Data Saver mode does not strip OTP verification payloads.
- [ ] TOTP validation window matches authenticator app tolerance (±1 step).
- [ ] No exported components leak OTP request intents (checked with Drozer).
- [ ] CI pipeline runs Espresso/UI Automator tests on Firebase Test Lab for API 21‑33 and reports zero failures.
If any item is NO, treat it as a blocker and investigate before proceeding.
Closing Takeaways
Testing two‑factor authentication on Android is not a checklist you can run once and forget. The flow straddles UI, networking, cryptography, and platform‑specific behaviors that evolve with each OS release. By combining a thorough test matrix, disciplined manual exploration, and layered automation (unit, Espresso, UI Automator, Appium), you gain confidence that both the nominal path and the myriad failure modes are covered.
Remember that production‑only bugs often stem from carrier filtering, background execution limits, notification settings, or device‑specific quirks that emulators do not replicate. Incorporate real‑device testing, especially with varied carriers and Android versions, and monitor logs for silent failures such as missing SMS Retriever broadcasts or leaked OTPs.
When you have the capacity, consider an autonomous, persona‑driven explorer like SUSA. It can wander through the app as a curious novice, an impatient power user, or an accessibility‑focused persona, exercising paths that scripted tests rarely touch—such as rapidly toggling airplane mode while the OTP dialog is visible, or attempting to paste a cloned OTP from another app’s notification. The insights from those exploratory runs frequently uncover edge cases that only appear under real‑world stress, complementing your deterministic test suites.
Finally, treat 2FA as a living contract between your client and your server. Any change to the OTP delivery mechanism, validity window, or backup authentication method should trigger a revisit of the matrix and the automation suite. By institutionalizing this practice, you reduce the risk of authentication‑related incidents and keep your users’ trust intact.
Test Your App Autonomously
Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.
Try SUSA Free