SMS OTP has been the default login for Indian apps for a decade. Two things make 2026 a good year to start moving off it. Android 17 restricts how apps can read OTP messages, and the RBI's new authentication rules, in force since 1 April 2026, push payment flows towards factors beyond SMS. Passkeys, built on Android's Credential Manager API, are the practical replacement. Here is how they fit together, the code you need, and the trade-offs to plan for.
Why SMS OTP is getting harder to rely on
Android 17's behaviour changes for all apps include an SMS OTP protection. If an app has permission to read SMS but is not the intended recipient of a WebOTP message, it cannot see that message until three hours after it arrives. If your app targets Android 17 (API level 37), the protection extends to standard SMS messages too. Google's advice is to move OTP reading to the SMS Retriever or SMS User Consent APIs.
That fixes auto-fill. It does not fix the bigger weakness: an OTP is a code a user can be tricked into reading out to a scammer.
On the payments side, the RBI's Authentication Mechanisms for Digital Payment Transactions Directions, 2025 require at least two factors for domestic digital payments, with at least one dynamic and unique to each transaction, and encourage alternatives such as device-based biometrics, according to Moneylife's summary. Card networks are already using passkeys for this. You do not need to be a payments app to benefit, but the direction of travel is clear.
Passkeys in one paragraph
A passkey is a key pair created on the user's device. The private key stays with the user's credential provider, unlocked by fingerprint, face or screen lock. Your server stores only the public key. At sign-in, the device signs a challenge from your server. There is no shared secret to phish or read aloud, and a passkey only works for the website or app it was created for.
Setup: dependencies and Digital Asset Links
Passkeys through Credential Manager need Android 9 (API level 28) or higher, per Google's create-passkeys guide. Add the Jetpack libraries:
dependencies {
implementation("androidx.credentials:credentials:<latest>")
implementation("androidx.credentials:credentials-play-services-auth:<latest>")
}
At the time of writing, the guide shows 1.7.0-alpha03. Pin the latest stable release if your team avoids alphas. The guide warns that versions before 1.2 throw an UnsupportedOperationException on Android 14.
Next, link your app and your domain. Host /.well-known/assetlinks.json on your sign-in domain, served with a 200 status, Content-Type: application/json and no redirects. Per the prerequisites page, it needs both relations:
[
{
"relation": [
"delegate_permission/common.handle_all_urls",
"delegate_permission/common.get_login_creds"
],
"target": {
"namespace": "android_app",
"package_name": "com.example.android",
"sha256_cert_fingerprints": ["SHA_HEX_VALUE"]
}
}
]
Use the SHA-256 fingerprint of your release signing key. If you use Play App Signing, that is the key in the Play Console, not your local upload key, and getting this wrong is the most common reason passkeys fail in production.
Creating a passkey
Your server generates the WebAuthn creation options (challenge, relying party, user, allowed algorithms) as JSON and stores the challenge. The app passes it to Credential Manager:
private val credentialManager = CredentialManager.create(context)
suspend fun registerPasskey(activity: Activity, requestJson: String) {
val request = CreatePublicKeyCredentialRequest(requestJson = requestJson)
try {
val result = credentialManager.createCredential(
context = activity,
request = request
) as CreatePublicKeyCredentialResponse
api.finishRegistration(result.registrationResponseJson)
} catch (e: CreateCredentialCancellationException) {
// User chose not to create one. Keep the existing login path.
} catch (e: CreateCredentialException) {
Log.w(TAG, "Passkey creation failed", e)
}
}
On the server, verify the response, check the origin against an allow list of your apps, and store the public key. Google's guide also recommends saving the AAGUID, which identifies the credential provider and helps users recognise their passkeys later.
Signing in
Following the sign-in guide, you can offer passkeys and saved passwords in the same request:
val request = GetCredentialRequest(
listOf(
GetPasswordOption(),
GetPublicKeyCredentialOption(requestJson = requestJson)
)
)
try {
val result = credentialManager.getCredential(activity, request)
when (val credential = result.credential) {
is PublicKeyCredential ->
api.finishSignIn(credential.authenticationResponseJson)
is PasswordCredential ->
api.passwordSignIn(credential.id, credential.password)
else -> Log.e(TAG, "Unexpected credential type")
}
} catch (e: NoCredentialException) {
showOtpFallback()
} catch (e: GetCredentialException) {
Log.e(TAG, "Sign-in failed", e)
}
Pass an activity context, as the guide recommends, so the system sheet launches correctly. NoCredentialException means the user has no saved credential or did not consent, which is your cue to fall back.
Trade-offs to plan for
You still need a fallback. Users on Android 8, new phones, or shared devices will need OTP or email for a while. Plan passkeys as the fast path, not the only path, at least in year one.
Recovery is the hard part. If a user loses access to their passkey, what proves they are who they say they are? Decide your account recovery flow before launch. If recovery falls back to SMS OTP, your weakest link is still SMS.
Server work is real. WebAuthn verification is not trivial. Use a maintained server library rather than writing the cryptography yourself.
Prompt at the right moment. Offer passkey creation right after a successful OTP login, when the user has just felt the friction you are removing.
A sensible rollout order
This sprint: move any OTP reading to SMS Retriever or User Consent, so Android 17 does not break auto-fill.
Next: publish
assetlinks.jsonand build server-side WebAuthn registration and verification.Then: offer passkey creation after OTP sign-in, and measure how many users accept.
Finally: make passkey the default sign-in for users who have one, with OTP as the fallback.
None of this removes SMS overnight. It moves your most active users onto something faster and much harder to phish, which is where most of your risk sits anyway.



