Auto-verify OTP on Android without SMS permissions using Kotlin. This guide shows you three proven methods — SMS Retriever API (recommended), User Consent API, and BroadcastReceiver — with complete code examples, app-hash generation, and server-side SMS formatting. We'll also flag which approach will get your app rejected from Google Play.
You know that moment: a user opens your banking or auth app, an OTP lands, and instead of hunting through their messages app to copy it manually, the app fills it in automatically. That one small feature feels magical. But implementing it is trickier than it looks — and picking the wrong method can get your app blocked from the Play Store.
In this tutorial, you'll learn when to use each method, how to generate your app's SMS hash in Kotlin, format server-side SMS correctly so it works, and handle fallback scenarios when auto-read fails. By the end, you'll know which approach fits your app's constraints.
Three Methods to Read SMS Automatically to Verify OTP (Android)
| Method | Permission | Play Store Safe? | Best For |
|---|---|---|---|
| SMS Retriever API | None required | ✅ Yes | When you control the SMS format on the server |
| User Consent API | None required | ✅ Yes | When you can't control the SMS format |
| BroadcastReceiver + READ_SMS | READ_SMS required |
🔴 Rejected unless default SMS app | Default SMS apps only |
READ_SMS or RECEIVE_SMS unless the app is a default SMS/messaging app. If your app is not a default SMS app, use Method 1 or Method 2. Using Method 3 will get your app rejected during review.
Method 1 — SMS Retriever API (Recommended) — Auto‑verify OTP in Android (Kotlin)
No permissions required. Google's SMS Retriever API intercepts a specific SMS that contains your app's unique hash — and delivers it directly to your app. The user never sees a permission prompt.
Step 1: Add the dependency
dependencies {
implementation "com.google.android.gms:play-services-auth-api-phone:18.0.2"
}
Step 2: Generate your app's hash
This is the step most tutorials skip — and it's the one that makes the API silently fail if you get it wrong. The SMS must contain an 11-character hash unique to your app. Generate it using this utility:
import android.content.Context
import android.content.pm.PackageManager
import android.util.Base64
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
object AppHashHelper {
fun getAppHash(context: Context): String {
val packageName = context.packageName
val signatures = context.packageManager
.getPackageInfo(packageName, PackageManager.GET_SIGNATURES)
.signatures
val appInfo = "$packageName ${signatures[0]}"
val messageDigest = MessageDigest.getInstance("SHA-256")
messageDigest.update(appInfo.toByteArray(StandardCharsets.UTF_8))
val hashBytes = messageDigest.digest()
val base64Hash = Base64.encodeToString(hashBytes, Base64.NO_WRAP or Base64.NO_PADDING)
// Return first 11 characters — this is your app hash
return base64Hash.substring(0, 11)
}
}
// Log it during development to get your hash
Log.d("AppHash", AppHashHelper.getAppHash(this))
Step 3: Format the SMS correctly on your server
The SMS must start with <#> and end with your 11-character app hash. The OTP can be anywhere in between:
<#> Your OTP is 483920. It is valid for 10 minutes. FA+9qCX9VSu
Rules the SMS must follow:
- Must start with
<#> - Must be no longer than 140 bytes
- Must end with the 11-character app hash on the last line
Step 4: Create the BroadcastReceiver
class SmsBroadcastReceiver : BroadcastReceiver() {
// Callback to pass the OTP back to the Activity/Fragment
var otpReceived: ((String) -> Unit)? = null
override fun onReceive(context: Context?, intent: Intent?) {
if (SmsRetriever.SMS_RETRIEVED_ACTION != intent?.action) return
val extras = intent.extras ?: return
val status = extras.get(SmsRetriever.EXTRA_STATUS) as? com.google.android.gms.common.api.Status
?: return
when (status.statusCode) {
SmsRetriever.RESULT_STATUS_OK -> {
val message = extras.getString(SmsRetriever.EXTRA_SMS_MESSAGE) ?: return
val otp = extractOtp(message)
otp?.let { otpReceived?.invoke(it) }
}
SmsRetriever.RESULT_TIMEOUT -> {
// SMS Retriever timed out after 5 minutes
}
}
}
// Extracts first 6-digit sequence from the message
private fun extractOtp(message: String): String? {
val regex = Regex("\\d{6}")
return regex.find(message)?.value
}
}
Step 5: Start the retriever and register the receiver
class OtpVerificationActivity : AppCompatActivity() {
private val smsBroadcastReceiver = SmsBroadcastReceiver()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_otp)
startSmsRetriever()
// Auto-fill OTP when received
smsBroadcastReceiver.otpReceived = { otp ->
binding.etOtp.setText(otp)
verifyOtp(otp)
}
}
private fun startSmsRetriever() {
val client = SmsRetriever.getClient(this)
client.startSmsRetriever()
.addOnSuccessListener {
// Retriever started — will listen for 5 minutes
}
.addOnFailureListener {
// Failed to start — handle gracefully, let user enter OTP manually
}
}
override fun onStart() {
super.onStart()
val intentFilter = IntentFilter(SmsRetriever.SMS_RETRIEVED_ACTION)
// API 34+ requires RECEIVER_NOT_EXPORTED for non-system broadcasts
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(smsBroadcastReceiver, intentFilter, RECEIVER_NOT_EXPORTED)
} else {
registerReceiver(smsBroadcastReceiver, intentFilter)
}
}
override fun onStop() {
super.onStop()
unregisterReceiver(smsBroadcastReceiver)
}
}
Method 2 — User Consent API — Read OTP SMS with Consent
No app hash needed. No SMS format requirements. Google shows the user a bottom sheet with the matching SMS and asks for consent to share it with your app. If they tap "Yes", you get the full message. Great for when you don't fully control the SMS format on your server.
import com.google.android.gms.auth.api.phone.SmsRetriever
import com.google.android.gms.auth.api.phone.SmsCodeRetriever
class OtpActivity : AppCompatActivity() {
private val SMS_CONSENT_REQUEST = 2
private val smsBroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == SmsRetriever.SMS_RETRIEVED_ACTION) {
val extras = intent.extras
val smsRetrieverStatus = extras?.get(SmsRetriever.EXTRA_STATUS)
as? com.google.android.gms.common.api.Status
when (smsRetrieverStatus?.statusCode) {
SmsRetriever.RESULT_STATUS_OK -> {
// Get consent intent — shows bottom sheet to user
val consentIntent = extras.getParcelable<Intent>(SmsRetriever.EXTRA_CONSENT_INTENT)
startActivityForResult(consentIntent, SMS_CONSENT_REQUEST)
}
}
}
}
}
private fun startUserConsent() {
// Optional: pass sender phone number to filter SMS
SmsRetriever.getClient(this).startSmsUserConsent(null)
}
// Handle user's response to the consent bottom sheet
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == SMS_CONSENT_REQUEST && resultCode == RESULT_OK) {
val message = data?.getStringExtra(SmsRetriever.EXTRA_SMS_MESSAGE) ?: return
val otp = Regex("\\d{6}").find(message)?.value
otp?.let { binding.etOtp.setText(it) }
}
}
override fun onStart() {
super.onStart()
startUserConsent()
val intentFilter = IntentFilter(SmsRetriever.SMS_RETRIEVED_ACTION)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(smsBroadcastReceiver, intentFilter, RECEIVER_NOT_EXPORTED)
} else {
registerReceiver(smsBroadcastReceiver, intentFilter)
}
}
override fun onStop() {
super.onStop()
unregisterReceiver(smsBroadcastReceiver)
}
}
Method 3 — BroadcastReceiver with READ_SMS (Default SMS Apps Only)
READ_SMS or RECEIVE_SMS and your app is not a default SMS/messaging app. This method is only included for completeness. Do not use this in a regular Android app.
If you are building a default SMS app and need direct SMS access, here's how to do it with the modern ActivityResultContracts API instead of the deprecated onRequestPermissionsResult:
class SmsDefaultAppActivity : AppCompatActivity() {
// Modern permission request — replaces deprecated onRequestPermissionsResult
private val requestSmsPermission = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val receiveSmsGranted = permissions[Manifest.permission.RECEIVE_SMS] == true
val readSmsGranted = permissions[Manifest.permission.READ_SMS] == true
if (receiveSmsGranted && readSmsGranted) {
// Permissions granted — register receiver
registerSmsReceiver()
} else {
// Permission denied — show explanation or fallback to manual entry
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestSmsPermission.launch(arrayOf(
Manifest.permission.RECEIVE_SMS,
Manifest.permission.READ_SMS
))
}
private fun registerSmsReceiver() {
// Register your BroadcastReceiver here
}
}
// SmsReceiver.kt
class SmsReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != "android.provider.Telephony.SMS_RECEIVED") return
val bundle = intent.extras ?: return
val pdus = bundle.get("pdus") as? Array<*> ?: return
for (pdu in pdus) {
val smsMessage = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val format = bundle.getString("format") ?: "3gpp"
SmsMessage.createFromPdu(pdu as ByteArray, format)
} else {
@Suppress("DEPRECATION")
SmsMessage.createFromPdu(pdu as ByteArray)
}
val sender = smsMessage.displayOriginatingAddress
val body = smsMessage.messageBody
// Process OTP
}
}
}
Best Practices
- Always fall back to manual entry — SMS Retriever has a 5-minute timeout. Network delays, carrier filtering, or incorrect SMS format can all cause it to fail silently. Always keep the OTP input field visible and functional so users can enter it manually if auto-fill doesn't work.
- Generate both debug and release hashes — your app hash is tied to your signing keystore. Debug and release builds have different hashes. Give both to your backend team so the Retriever API works in both environments.
- Use Method 1 over Method 2 for better UX — SMS Retriever API fills the OTP without any user interaction. User Consent API requires a tap on a bottom sheet. When you control the SMS format, Method 1 is a smoother experience.
- Register the receiver in onStart/onStop — not in onCreate/onDestroy. Registering in onStart ensures the receiver is active whenever the screen is visible and automatically unregistered when it's not.
- Use RECEIVER_NOT_EXPORTED on Android 14+ — API 34 requires explicit export flags for dynamically registered receivers that don't handle system broadcasts. Always add this flag to avoid a crash on newer devices.
Frequently Asked Questions
How do I auto-read OTP SMS in Android without permissions?
Use Google's SMS Retriever API with Kotlin — no SMS permissions required. The SMS must start with <#> and end with your app's 11-character app hash to be delivered silently to your app.
How can I auto-verify OTP on Android using Kotlin?
Start SmsRetrieverClient, register a BroadcastReceiver, extract the OTP with a regex (e.g., \d{4,6}), and verify it on your server. Prefer SMS Retriever for silent auto-fill or User Consent API when you can't control SMS format.
Why is my SMS Retriever API not working for OTP auto-fill?
Common causes: incorrect 11-character app hash, different signing keystore (debug vs release), SMS longer than 140 bytes, missing leading <#>, or carrier filtering. Verify app hash generation and SMS template.
Can I use READ_SMS permission to auto-read OTP in Android?
Only if your app is the device's default SMS app — Google Play restricts READ_SMS and RECEIVE_SMS for non-default messaging apps. Use SMS Retriever or User Consent API instead.
What's the difference between SMS Retriever API and User Consent API for OTP?
SMS Retriever is silent and requires server-controlled SMS format + app hash. User Consent API shows a bottom sheet asking users to approve sharing the SMS and is suitable when you don't control the SMS content.
How do I generate the app hash for SMS Retriever in Kotlin?
Compute the 11-character app signature hash from your package name and signing key (debug and release differ). Log it in development or compute it server-side and include it in the SMS template.
What SMS format should my server send for SMS Retriever OTP auto-verification?
Use: <#> Your OTP is 123456. It expires in 10 minutes. followed by the 11-character app hash on the last line. Start with <#>, keep under 140 bytes, and include the app hash.
How do I extract OTP from SMS in Kotlin reliably?
Use a regex like \b\d{4,6}\b to capture 4–6 digit codes, trim and validate them, and always verify the OTP on the server before granting access.
What are the three methods to auto-read OTP SMS on Android?
Method 1: SMS Retriever API (no permissions, silent, recommended).
Method 2: User Consent API (no permissions, shows consent sheet).
Method 3: BroadcastReceiver + READ_SMS (Google Play rejects unless default SMS app).
How do I register a BroadcastReceiver for SMS Retriever in Kotlin?
Create a class extending BroadcastReceiver, override onReceive(), check for SmsRetriever.SMS_RETRIEVED_ACTION, extract the SMS from extras, and parse the OTP. Register in onStart() and unregister in onStop().
- SMS Retriever API — no permission, silent auto-fill, requires server-side SMS format with app hash
- User Consent API — no permission, shows bottom sheet, no SMS format requirement
- READ_SMS BroadcastReceiver — Play Store rejected unless you're a default SMS app
- Always generate debug + release hashes separately — they differ by keystore
- SMS must start with
<#>and end with the 11-char hash for Retriever API - Register receiver in onStart/onStop — not onCreate/onDestroy
- Add
RECEIVER_NOT_EXPORTEDflag on Android 14+ (API 34) - Always provide manual OTP entry fallback — Retriever times out after 5 minutes
Works fine while the app is running. Throws error when the app is closed. mListener.messageReceived(messageBody); throws Null object reference. Please help me
ReplyDeleteI have republished the Article. Now you can choose from 3 different methodsm depends on your app’s specific needs.
Delete