Read SMS automatically to verify OTP - Android, Kotlin

You've been there — you're using a banking app, an OTP arrives, and instead of making you switch to your messages app and type it manually, the app just fills it in automatically. That one small feature feels magical to users. And it's one of those things that's slightly more involved to implement than it looks.

Illustration of Android app automatically reading SMS OTP and verifying user login

Automatic OTP verification flow in Android apps using SMS-based authentication

There are three ways to auto-read OTPs in Android — each with different trade-offs around permissions, Play Store policies, and how much control your server has over the SMS format. This guide covers all three, flags the one that will get your app rejected from the Play Store, and shows you exactly what the SMS needs to look like for each method to work.

Three Methods — Which One Should You Use?

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
⛔ Play Store Warning: Since 2019, Google Play rejects apps that request 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.
Diagram comparing SMS Retriever API, User Consent API, and BroadcastReceiver READ_SMS methods for OTP reading in Android

Comparison of three Android OTP reading approaches: SMS Retriever API, User Consent API, and BroadcastReceiver with READ_SMS permission

Method 1 — SMS Retriever API (Recommended)

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))
💡 Important: Your app hash changes with each signing keystore. Generate separate hashes for your debug keystore (development) and release keystore (production). Pass both to your backend so the SMS works in both environments.

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

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)

⛔ Stop — read this first. Google Play will reject your app if it requests 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 without permissions?
Use the SMS Retriever API — no permissions needed. The SMS must start with <#> and end with your app's 11-character hash. Google Play Services intercepts it and delivers it directly to your app silently.

Why is my SMS Retriever API not working?
Most likely an incorrect app hash. The hash must match the keystore used to sign the installed build. Debug and release builds have different hashes — generate both and give both to your backend. Also verify the SMS starts with <#>, ends with the hash on the last line, and is under 140 bytes.

Can I use READ_SMS permission to auto-read OTP?
Technically yes, but Google Play will reject your app unless it's a default SMS app. Since 2019, READ_SMS and RECEIVE_SMS are restricted to default messaging apps only. Use SMS Retriever API or User Consent API for all other apps.

What is the difference between SMS Retriever and User Consent API?
SMS Retriever works silently with no user interaction — requires server-side SMS format control. User Consent shows a bottom sheet asking the user to confirm. Use Retriever when you control the SMS format; use User Consent when you don't.

📝 Summary
  • 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_EXPORTED flag on Android 14+ (API 34)
  • Always provide manual OTP entry fallback — Retriever times out after 5 minutes

Pragnesh Ghoda

A forward-thinking developer offering more than 8 years of experience building, integrating, and supporting android applications for mobile and tablet devices on the Android platform. Talks about #kotlin and #android

2 Comments

Please let us know about any concerns or query.

  1. Works fine while the app is running. Throws error when the app is closed. mListener.messageReceived(messageBody); throws Null object reference. Please help me

    ReplyDelete
    Replies
    1. I have republished the Article. Now you can choose from 3 different methodsm depends on your app’s specific needs.

      Delete
Previous Post Next Post

Contact Form