The Android SMS Retriever API gives you permission-free OTP autofill — no RECEIVE_SMS prompt, no "allow this app to read your messages" friction. The catch: your outbound SMS body has to contain an 11-character hash that Google's Play Services uses to confirm the message is meant for your specific app. This guide walks through computing it, embedding it, and testing it without burning OTPs.
What the app-hash actually is
Google's SMS Retriever client looks at every inbound SMS on the device and finds the line containing an 11-char string that matches:
Base64(SHA-256(<package_name> + " " + <release_signing_cert_sha256>)) truncated to the first 11 characters
So the hash uniquely identifies one app on one device, signed with one keystore. The com.example.app debug build has a different hash than the same package signed for Play Store release. Same package signed by your CI keystore is different from the same package signed by Play App Signing (Google's managed keystore).
That last point is the #1 source of confusion. Most teams have at least 3 different signing certificates in play simultaneously:
- Your local debug keystore (Android Studio default).
- Your release keystore (manually generated, kept in a vault).
- Play App Signing's managed certificate (Google holds the key after you upload your release APK).
Each one produces a different app-hash. The hash you embed in your SMS body must match the certificate that actually signs the APK shipped to the user — so for production traffic, it's almost always the Play App Signing certificate.
Computing the hash — three methods
Method 1: Google's official AppSignatureHelper
The cleanest way. Drop Google's reference helper into a debug-only source set, run it once on a real device, log the hash, and save the value.
// app/src/debug/java/com/example/util/AppSignatureHelper.kt
// Copy the official version from:
// https://developers.google.com/identity/sms-retriever/verify
class AppSignatureHelper(private val context: Context) {
fun getAppSignatures(): ArrayList<String> {
// Sha-256 hash signing certificate + package name, base64-encoded,
// truncated to 11 chars. Production-quality code is on the URL above.
...
}
}
// Then in MainActivity.onCreate (debug build only):
val helper = AppSignatureHelper(applicationContext)
val hashes = helper.getAppSignatures()
Log.d("SmsRetriever", "App hash: ${hashes.firstOrNull()}")The catch: this runs against whatever cert currently signs the installed APK. If you run the debug build on a device, you get the debug hash. If you sideload your release APK, you get the release hash. You won't get the Play App Signing hash this way — that certificate is only used by Google's signing server.
Method 2: Play Console — production hash
For production, the easiest reliable source of truth is the Play Console:
- Open Play Console → your app → Test and release → App integrity.
- Find the "App signing key certificate" section (this is Play App Signing's cert if it's enabled; otherwise your uploaded one).
- Copy the SHA-256 fingerprint shown there.
- Compute the hash yourself from
package_name+ " " + the fingerprint, then base64-encode and truncate.
A small one-liner with Python's stdlib:
import hashlib, base64
package = "com.example.app"
sha256_hex = "AB:CD:..." # paste from Play Console, with colons
sha256_bytes = bytes.fromhex(sha256_hex.replace(":", ""))
combined = (package + " ").encode() + sha256_bytes
digest = hashlib.sha256(combined).digest()
hash11 = base64.b64encode(digest).decode()[:11]
print(hash11)Method 3: ./gradlew computeAppHash
The QuickAuth Android SDK ships a Gradle task that computes the hash for whichever signing config is wired to your release build. If you use Play App Signing, the SDK reads the fingerprint from a quickauth.releaseSha256 Gradle property (which you set after copying from the Play Console — same data, different source path):
# gradle.properties quickauth.releaseSha256=AB:CD:12:34:... # then: ./gradlew :app:computeAppHash
Output:
================ QuickAuth SMS Retriever app-hash ================ Package: com.example.app Cert SHA-256: AB:CD:... App hash: +1JjN9xLqsM ==================================================================
Embedding the hash in your SMS body
The SMS body has a strict shape SMS Retriever recognises:
<#> Your code is 123456 +1JjN9xLqsM
The first line starts with <#> (the "private OTP" marker), contains the human-readable message, and the last line is your 11-char hash. Don't put the hash on the same line as the code; don't add any text after it.
With QuickAuth's backend, you don't have to think about this — we append the matching hash automatically once you've uploaded your release SHA-256 to the dashboard. Outbound bodies become something like <#> Your QuickAuth code is {{code}}
+1JjN9xLqsM.
The Android side — wiring the listener
Once your outbound SMS contains the hash, the Android SDK call is two lines:
// in your OTP screen: val client = SmsRetriever.getClient(this) client.startSmsRetriever() // then register a BroadcastReceiver for // com.google.android.gms.auth.api.phone.SMS_RETRIEVED // (5-minute timeout, then the API gives up).
QuickAuth's Android SDK does this for you — you call QuickAuth.auth.observeOTP() and get a Flow<String> of inbound OTP codes. Internally we register the receiver, parse the body, surface the code, and unregister on dispose. See the Android SDK docs.
Testing without burning real OTPs
The most expensive mistake here is sending 50 real OTPs to your own phone while debugging. Two cheaper approaches:
- Inject a fake SMS via ADB. From your dev machine:
adb emu sms send +919876543210 "<#> Your code is 123456" "+1JjN9xLqsM"
Note: this only works on Android emulators, not real devices. - Use QuickAuth's test mode. Test keys (
qa_test_***) don't send real SMS — they accept any phone and return the fixed OTP123456. The SDK's receiver still fires with a simulated body containing the right hash, so you can verify the autofill flow end-to-end without paying for a single OTP.
Common failure modes
If the SDK isn't picking up the OTP, check in this order:
- Is the hash correct for the cert that actually signs the installed APK? 80% of "it works in debug but not production" reports trace back to a Play App Signing cert mismatch.
- Does the SMS body have the literal
<#>prefix? Some SMS aggregators strip it as a control character. Send yourself a test SMS and inspect the raw body in the Messages app. - Is the hash on its own line? No trailing spaces, no zero-width characters, no extra newlines after.
- Is Google Play Services installed and recent?SMS Retriever needs Play Services 10.0+. Devices in regions where Play Services isn't bundled (some China-sold devices) won't have it.
- Did you start the retriever before the SMS arrived? The retriever has a 5-minute window from
startSmsRetriever(). If you trigger the send before calling start, the SMS will arrive but the API won't surface it.
iOS comparison
For completeness: iOS does not have an equivalent app-hash. ThetextContentType(.oneTimeCode) system input automatically detects OTP-shaped messages and offers a "From Messages" keyboard suggestion. No hash required, no permissions, no SDK plumbing. The flip side: there's no way to auto-submit — users must tap the suggestion. Android's permission-free flow is actually a slightly better UX once you've gotten the hash right.
For more on the iOS side, see QuickAuth iOS SDK docs.