Irbisa · Cheatsheet

Mobile Security (Cross-platform)

Keychain/Keystore, secure storage, certificate pinning, jailbreak/root, App Transport Security

Senior Developer · 24 questions

  1. 01

    Which OWASP Mobile Top 10 risks do you actually check for in an Android app?

    I use the OWASP Mobile Top 10 (2024) as a checklist of controls: no embedded secrets or plain-text storage, authorization on the server, TLS without trust-all, validated deep links and non-exported components, vetted SDKs, and R8 as a cost-raiser only.

  2. 02

    What is the difference between hashing, encryption and encoding, and where does the encryption key live on Android?

    Encoding changes the format and anyone can reverse it, encryption hides data from anyone without the key, and hashing is a one-way fingerprint that cannot be reversed at all.

  3. 03

    How do you keep an API key out of git and out of the APK, and when is hiding it not enough?

    Keep it out of git by reading it from local.properties or a CI secret at build time.

    // 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…
  4. 04

    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?

    Both platforms block cleartext HTTP by default, so a release build that sends it carries an opt-out somebody committed: an Android network security config, or an ATS exception in Info.plist.

    <!-- 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>…
  5. 05

    How does the app decide a server certificate is genuine, and how do you pin it on Android?

    The TLS stack checks that the certificate chain is signed up to a root CA the device trusts, is within its validity dates and names the host you dialled.

    // 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()…
  6. 06

    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()

    It switches off TLS authentication completely: any certificate from anyone is accepted for any hostname, so whoever sits on the network path can read and change every request.

  7. 07

    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)

    The pins are registered for example.com, but the client calls api.example.com.

  8. 08

    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)    }}

    The key is compiled into the APK, so anyone who unpacks the app can decrypt every user's token; the fixed all-zero IV and the missing integrity check make it weaker still.

  9. 09

    Your HTTP logger already redacts Authorization, yet a refresh token turns up in a crash report. Where else do tokens leak into logs?

    Anywhere an object holding the token is turned into a string: a data class's generated toString(), an exception message, a crash breadcrumb or an SDK's own debug log.

    // 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)"}…
  10. 10

    How do you keep sensitive content out of screenshots, screen recordings and the clipboard?

    Set FLAG_SECURE on the window to block screenshots, blank recordings and the Recents thumbnail, and mark copied secrets with EXTRA_IS_SENSITIVE and clear them after a timeout.

    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() }…
  11. 11

    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?

    This is tapjacking: another app draws over yours and the user's tap lands on your button.

    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) {…
  12. 12

    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?

    A custom scheme like myapp:// is not owned by anyone, so another app can receive links meant for you.

    // 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()) {…
  13. 13

    Find the bug in this deep link handler that opens a partner page in a WebView with 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}"))    }}

    endsWith("example.com") also accepts evilexample.com, a domain anyone can register.

  14. 14

    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?

    Deleting an iOS app does not delete its Keychain items.

    // 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)…
  15. 15

    What is the Play Integrity API, what does it attest, and why trust it over a client-side root check?

    Play Integrity gives your server a Google-signed verdict on a request: is this the binary Play distributed, does the device pass integrity checks, is the user licensed.

    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() {…
  16. 16

    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?

    A passkey is a key pair bound to your domain and held by the platform, so the app never handles a shared secret and a phishing site has nothing to collect.

    // 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…
  17. 17

    Where does a private key actually live on Android and iOS, and how do you guarantee it cannot be extracted?

    Generate the key inside secure hardware and never hold it yourself: the Android Keystore and the iOS Secure Enclave only let your app ask the key to sign or decrypt, and key attestation lets your server verify that this is really the case.

    // 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…
  18. 18

    Your app unlocks itself when the biometric API returns true. What is wrong with that, and what does a correct flow look like?

    A biometric API that returns true proves nothing: whoever can patch the app can patch the boolean.

    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)…
  19. 19

    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?

    R8 renames your classes and members and strips unused code, but strings, framework calls, resources and the logic itself stay readable, so patching out a check is an afternoon's work.

    // 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…
  20. 20

    The device storage is already encrypted by the OS. When does encrypting your own files actually add anything?

    Device encryption (file-based on Android, Data Protection on iOS) protects a phone that is off or not yet unlocked after boot.

  21. 21

    Your WebView exposes a @JavascriptInterface object so the page can fetch the auth token. What is wrong with that, and what replaced it?

    A JavaScript interface is attached to the WebView, not to an origin.

    // 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(…
  22. 22

    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?

    The access token is self-contained, like a JWT: the API trusts it until it expires and never asks the server whether the session still exists, so logout has to be a server-side revocation, not a local wipe.

  23. 23

    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?

    There is no stable hardware identifier available to apps any more, so reshape the requirement: ask whether this device has already claimed the trial, not who this device is, and keep that fact on the server.

  24. 24

    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?

    Nothing at runtime will catch it: a library runs in your process with your permissions, your files and your Keystore keys.