Irbisa · Cheatsheet
Mobile Security (Cross-platform)
Keychain/Keystore, secure storage, certificate pinning, jailbreak/root, App Transport Security
Which OWASP Mobile Top 10 risks do you actually check for in an Android app?
What is the difference between hashing, encryption and encoding, and where does the encryption key live on Android?
How do you keep an API key out of git and out of the APK, and when is hiding it not enough?
// app/build.gradle.ktsval localProps = java.util.Properties().apply { val file = rootProject.file("local.properties") // git-ignored if (file.exists()) file.inputStream().use { load(it) }}val mapsKey: String = System.getenv("MAPS_API_KEY") // CI secret…A release build turns out to be talking to the staging server over plain http. What is supposed to stop that on each platform, and who switched it off?
<!-- src/debug/res/xml/network_security_config.xml: debuggable builds only --><network-security-config> <domain-config cleartextTrafficPermitted="true"> <domain includeSubdomains="true">staging.example.com</domain> </domain-config> <debug-overrides>…How does the app decide a server certificate is genuine, and how do you pin it on Android?
// placeholders: pin your real SPKI hashes// Pin the SubjectPublicKeyInfo hash; the second pin is the backup key you rotate toval pinner = CertificatePinner.Builder() .add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") .add("api.example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") .build()…A teammate added this so the app works against a staging server with a self-signed certificate. What does it do to the release build?
val trustAll = object : X509TrustManager { override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {} override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {} override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()} val sslContext = SSLContext.getInstance("TLS").apply { init(null, arrayOf(trustAll), SecureRandom())} val client = OkHttpClient.Builder() .sslSocketFactory(sslContext.socketFactory, trustAll) .hostnameVerifier { _, _ -> true } .build()Find the bug: with this pinning in place, a proxy whose CA sits in the test phone's system store still decrypts every API call.
private const val BASE_URL = "https://api.example.com/v1/" private val pinner = CertificatePinner.Builder() .add("example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") // current key .add("example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") // backup key .build() val client = OkHttpClient.Builder() .certificatePinner(pinner) .build()val api: ShopApi = Retrofit.Builder() .baseUrl(BASE_URL) .client(client) .addConverterFactory(MoshiConverterFactory.create()) .build() .create(ShopApi::class.java)This helper encrypts the user's token before it is saved. What is wrong with it?
object TokenCrypto { private val key = SecretKeySpec("k3y-f0r-t0k3ns!!".toByteArray(), "AES") private val iv = IvParameterSpec(ByteArray(16)) fun encrypt(token: String): String { val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") cipher.init(Cipher.ENCRYPT_MODE, key, iv) val bytes = cipher.doFinal(token.toByteArray()) return Base64.encodeToString(bytes, Base64.NO_WRAP) }}Your HTTP logger already redacts
Authorization, yet a refresh token turns up in a crash report. Where else do tokens leak into logs?// Redact in the type, so no future log line or exception can print the secret@JvmInlinevalue class RefreshToken(val raw: String) { override fun toString() = "RefreshToken(${raw.length} chars)"}…How do you keep sensitive content out of screenshots, screen recordings and the clipboard?
class CardDetailsActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Blocks screenshots, blanks recordings and the Recents thumbnail for this window window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) setContent { CardDetailsScreen() }…A user says a fake dialog appeared on top of your payment screen and they tapped Confirm through it. What could the app have done about that?
class CheckoutActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Compose has no modifier for this: set it on the host view window.decorView.filterTouchesWhenObscured = true if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {…Any app on the device can register your
myapp://scheme. What breaks because of that, and what has to be true before you act on an inbound link?// Every inbound link is an unauthenticated request from an unknown callerfun parseLink(uri: Uri): Route? { if (uri.scheme != "https" || uri.host != "example.com") return null // exact allow-list val segments = uri.pathSegments val id = segments.getOrNull(1)?.toLongOrNull() ?: return null return when (segments.firstOrNull()) {…Find the bug in this deep link handler that opens a partner page in a
WebViewwith the user's token.class PartnerWebActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // myapp://web?url=https://shop.example.com/offer/42 val url = intent.data?.getQueryParameter("url") ?: return finish() val host = Uri.parse(url).host.orEmpty() if (!host.endsWith("example.com")) return finish() val webView = WebView(this).also { setContentView(it) } webView.loadUrl(url, mapOf("Authorization" to "Bearer ${session.accessToken}")) }}Testers delete the app, install it again, and are still signed in as the previous user. Why does that happen on iOS and not on Android?
// UserDefaults is deleted with the app, the Keychain is not: that gap detects a reinstallfunc purgeKeychainOnFreshInstall() { let flag = "hasLaunchedBefore" guard !UserDefaults.standard.bool(forKey: flag) else { return } // scope by service so shared groups survive SecItemDelete([kSecClass: kSecClassGenericPassword, kSecAttrService: "auth"] as CFDictionary)…What is the Play Integrity API, what does it attest, and why trust it over a client-side root check?
class IntegrityClient(context: Context, private val projectNumber: Long) { private val manager = IntegrityManagerFactory.createStandard(context) private var provider: StandardIntegrityTokenProvider? = null // Warm up once, ahead of time: preparing takes a few seconds suspend fun prepare() {…Product wants to drop passwords and ship passkeys. What has to exist on your domain, on the server and in the app before that works?
// Registration: the server issues the options, the platform creates the key pairsuspend fun registerPasskey(activity: Activity, optionsJson: String) { val response = CredentialManager.create(activity).createCredential( activity, CreatePublicKeyCredentialRequest(requestJson = optionsJson) ) as CreatePublicKeyCredentialResponse api.finishRegistration(response.registrationResponseJson) // server stores the public key…Where does a private key actually live on Android and iOS, and how do you guarantee it cannot be extracted?
// A hardware-backed signing key; only the public half and the attestation chain leave the devicefun createDeviceKey(alias: String, serverChallenge: ByteArray): List<Certificate> { fun spec(strongBox: Boolean) = KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN) .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) .setDigests(KeyProperties.DIGEST_SHA256) .setAttestationChallenge(serverChallenge) // the server looks for it in the chain…Your app unlocks itself when the biometric API returns true. What is wrong with that, and what does a correct flow look like?
fun createBiometricKey(alias: String) { val spec = KeyGenParameterSpec.Builder( alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT ) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)…An attacker decompiles your APK with jadx, patches out a check and re-signs it. What does R8 actually hide, and how do you detect repackaging or Frida hooks?
// Tripwires, not locks: the result goes to the server as a signal and unlocks nothingprivate const val PLAY_SIGNING_SHA256 = "3A:9F:..." // the Play app signing key, not the upload key fun tamperSignals(context: Context): List<String> = buildList { val info = context.packageManager.getPackageInfo( context.packageName, PackageManager.GET_SIGNING_CERTIFICATES…The device storage is already encrypted by the OS. When does encrypting your own files actually add anything?
Your WebView exposes a
@JavascriptInterfaceobject so the page can fetch the auth token. What is wrong with that, and what replaced it?// wrong: every frame the WebView ever loads can call this, ad iframes included// webView.addJavascriptInterface(TokenBridge(), "Android") fun installBridge(webView: WebView, onClose: () -> Unit) { if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) return WebViewCompat.addWebMessageListener(…A user logs out from the web after losing their phone, but the API accepts the phone's access token for another hour. What is the design mistake?
Fraud asks you to stop one phone from claiming the free trial ten times. What can you actually use as a device identity in 2026?
A dependency three levels down starts sending data to a host you have never heard of. What in your build and release process is supposed to catch that?