← All problems

Mobile Core Concepts

Security and Crypto

Why a sensitive feature without the platform's crypto and attestation primitives is a liability, and the security seams an interviewer probes for anything touching tokens, keys, or user data.

Free~22 min

Security on Android is a threat model plus a small set of platform primitives that each defend against one specific attacker. The same auth token that looks "fine" in SharedPreferences is one adb backup from a developer's laptop and one rooted device from any user. This page covers the primitives every problem touching identity, secrets, payments, or DRM eventually leans on: the Android Keystore, BiometricPrompt, certificate pinning with Network Security Config, scoped storage, encrypted preferences, the APK signing schemes (v1-v4), and Play Integrity. End-to-end message encryption belongs to the Messenger design; PII redaction lives in the crash-reporting design; this page is the substrate they both build on.

The threat model

Every primitive on this page exists because of a specific attacker. Naming the attacker is what stops candidates from over-engineering or under-engineering:

AttackerWhat they can doPrimitive that defends
Another app on the deviceRead unprotected files, intercept implicit intents, call your exported componentsScoped storage, FileProvider URI grants, RECEIVER_NOT_EXPORTED, signature-level permissions
User with adb backup or rootRead the app's private data directory; pull SharedPreferences XMLKeystore-backed encryption of values at rest (EncryptedSharedPreferences, encrypted Room)
Network adversary on the pathIntercept TLS with a CA they control or installedCertificate pinning + Network Security Config
Adversary with the user's unlocked deviceUse the app in the foregroundBiometricPrompt-gated key use; re-auth windows
Adversary running a tampered clientSend forged requests, replay attestation, claim to be your appApp signing + Play Integrity verdicts; server-side enforcement

The candidate move is to name the attacker first, then map to the primitive. "Protect the auth token against a rooted user pulling the backup" is well-shaped; "we need to be secure" is not.

Android Keystore

Android Keystore is the substrate. It is a system service that holds key material in a trust zone the app process cannot read. Code generates a key by name and later uses the handle to sign, encrypt, or decrypt; the raw bytes never enter the app's address space. The keystore2 daemon brokers requests; the actual key material lives in one of three places, in increasing strength:

  • Software-only - encrypted blob on disk under the device root key. Better than nothing, but a sufficiently motivated attacker who roots the device can extract it.
  • TEE (Trusted Execution Environment) - keys live on the application processor in an isolated execution mode (ARM TrustZone). The bytes never enter Linux memory. Standard on every modern device.
  • StrongBox - dedicated secure element chip, physically isolated from the application processor. Pixel 3+, recent Samsung flagships, a few other OEMs. Resistant to side-channel attacks the TEE is not.
val spec = KeyGenParameterSpec.Builder("session_key",
        PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
    .setBlockModes(BLOCK_MODE_GCM)
    .setEncryptionPaddings(ENCRYPTION_PADDING_NONE)
    .setUserAuthenticationRequired(true)            // require biometric / PIN
    .setUnlockedDeviceRequired(true)                // KEYGUARD must be unlocked
    .setIsStrongBoxBacked(true)                     // prefer SE; falls back to TEE
    .build()
KeyGenerator.getInstance(KEY_ALGORITHM_AES, "AndroidKeyStore").run {
    init(spec); generateKey()
}

Three flags do the heavy lifting. setUserAuthenticationRequired(true) binds the key to a user-presence check, fronted by BiometricPrompt. setUnlockedDeviceRequired(true) refuses use while the screen is locked. setIsStrongBoxBacked(true) requests the SE and falls back to TEE if none exists.

What distinguishes Keystore from "encrypt the value yourself" is key attestation. The Keystore signs a certificate chain rooted in a Google-controlled CA asserting "this key was generated on hardware, in the TEE, with these flags." A backend that validates the chain knows the client really has a hardware-backed key - the foundation of device-bound auth and DRM.

Rendering diagram…

BiometricPrompt

BiometricPrompt is the system UI for "prove the human is here" - fingerprint, face, or device PIN as fallback. The part that matters for system design is how it composes with Keystore: the prompt unlocks the use of a Keystore key for one operation, via a CryptoObject that wraps a Cipher, Signature, or Mac initialized with a key whose setUserAuthenticationRequired(true) is set.

val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
    init(Cipher.ENCRYPT_MODE, keystore.getKey("session_key", null))
}
BiometricPrompt(activity, executor, callback).authenticate(
    PromptInfo.Builder()
        .setTitle("Sign in to Example")
        .setAllowedAuthenticators(BIOMETRIC_STRONG)   // Class 3 only
        .build(),
    BiometricPrompt.CryptoObject(cipher),
)
// Inside onAuthenticationSucceeded, result.cryptoObject.cipher is now usable.

BIOMETRIC_STRONG (Class 3) is the only level allowed to gate Keystore key use. Class 2 (BIOMETRIC_WEAK) - older face unlock, some optical fingerprint sensors - can show the prompt but cannot unlock a setUserAuthenticationRequired key. Most flows allow BIOMETRIC_STRONG or DEVICE_CREDENTIAL and let the OS pick the right prompt, since not every device has a Class 3 sensor.

A common shape: encrypt the auth token at rest with a Keystore key whose use requires biometric, and only decrypt it on app launch. The token never sits in plaintext on disk; the prompt is the gate to bring it into memory.

Certificate pinning and Network Security Config

TLS validates that the server's cert chains up to some trust anchor on the device. That store includes hundreds of CAs, plus user-installed roots (MDM profiles, debugging proxies, malware). Pinning says: "the legitimate cert chains through this specific public key; any other valid-looking chain is a forgery."

Pin the SubjectPublicKeyInfo (SPKI) hash, not the leaf cert hash. SPKI stays stable across cert rotations as long as the keypair is reused; pinning the leaf bricks the install base on the next 90-day rotation.

<!-- res/xml/network_security_config.xml -->
<network-security-config>
  <domain-config>
    <domain includeSubdomains="true">api.example.com</domain>
    <pin-set expiration="2026-12-31">
      <pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
      <pin digest="SHA-256">BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin>
    </pin-set>
  </domain-config>
</network-security-config>

Two pins, always: the production key and a pre-deployed rotation key held offline. The expiration attribute matters: past the date, pinning is not enforced, so a forgotten config doesn't permanently brick the app - at the cost of silently weakening security, so ship a CI alert before expiry.

Network Security Config also configures trust anchors. By default, debug builds trust user-installed CAs (so Charles Proxy works) and release builds do not. An app that needs a corp MITM proxy in release has to opt user CAs in explicitly:

<base-config cleartextTrafficPermitted="false">
  <trust-anchors>
    <certificates src="system"/>
    <!-- <certificates src="user"/> only if you really must -->
  </trust-anchors>
</base-config>

cleartextTrafficPermitted="false" rejects every plain http:// request - the cheapest hardening move and the default on API 28+. The implementation side of SPKI pinning lives in the Networking Client design.

Scoped storage

Pre-Android 10, any app holding READ_EXTERNAL_STORAGE could walk every photo, document, and arbitrary file in /sdcard. API 29 (Q) introduced scoped storage; API 30 (R) enforced it. The model:

  • App-scoped directory - context.getExternalFilesDir(...) is yours alone. No permission needed. Cleared on uninstall.
  • MediaStore - photos, videos, audio. READ_MEDIA_IMAGES / _VIDEO / _AUDIO permissions (API 33+) gate access; pre-33 it was READ_EXTERNAL_STORAGE. You read others' media; you can only modify your own.
  • Storage Access Framework - the user picks a file or directory via ACTION_OPEN_DOCUMENT / ACTION_OPEN_DOCUMENT_TREE; you get a content:// URI with the scope the user granted, no broad permission required.
  • MANAGE_EXTERNAL_STORAGE - the escape hatch for file managers and backup apps. Play Console reviews it; ordinary apps will not be granted it.

The opt-out requestLegacyExternalStorage="true" was honored only when targeting API 29 on devices below API 30. On API 30+ it is ignored.

The threat-model framing: scoped storage isn't really about your app's privacy; it's about preventing every other app from reading user content without permission. Side benefit: your data directory is invisible to other apps without root, which is why "store the token in an internal-storage file" is a reasonable baseline before reaching for encryption at rest.

EncryptedSharedPreferences, Tink, and the deprecation

Auth tokens, refresh tokens, session keys - anything a rooted user with a pulled backup must not read - go through encryption at rest. The Jetpack pattern is EncryptedSharedPreferences: API-compatible with SharedPreferences, but every value is AES-256-GCM and every key AES-256-SIV (deterministic so lookup works), with the data-encryption key wrapped under a Keystore master key. Tink is the crypto library under the hood.

val masterKey = MasterKey.Builder(context)
    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
    .build()
val secure = EncryptedSharedPreferences.create(
    context, "auth", masterKey,
    PrefKeyEncryptionScheme.AES256_SIV,
    PrefValueEncryptionScheme.AES256_GCM,
)

The wrinkle as of 2024-25: androidx.security:security-crypto is deprecated. Current guidance is to use Tink directly, or wrap values with Cipher + a Keystore key and persist via DataStore. The shape stays the same (Keystore-wrapped DEK over payloads at rest); the wrapper class is on its way out. Naming the deprecation is the staff move; see Persistence for where this sits relative to Room, DataStore, and MMKV.

App signing

Every APK is signed. The signature is what the OS uses to decide "is this an update to the same app." Schemes have stacked over the years; modern devices verify the strongest one they understand and ignore the rest.

SchemeWhat it signsWhy it matters
v1 (JAR)Each file's entry in META-INF/; ZIP-basedLegacy; vulnerable to ZIP tampering between sig and verification. Required on API 23 and below
v2Whole APK as one binary blobAPI 24+ default; faster verify, no per-entry trust
v3Same as v2 plus a key-rotation lineageAPI 28+; one signed assertion that "old key authorized rotation to new key"
v4Incremental, Merkle-tree basedAPI 30+; needed for App Bundles and Play streaming installs (adb install --incremental)

The signing-cert hash matters beyond install verification. The SHA-256 of the cert is what assetlinks.json (App Links) checks, what Play Integrity attests, and what signature-level permissions match on.

Play App Signing changes who holds what. The developer holds an upload key; Google holds the deployment key (rotated server-side). The uploaded artifact is re-signed before distribution: a security upgrade (the long-lived key isn't on a laptop) and the only path for App Bundles. The v3 lineage is how you migrate an existing app onto Play App Signing without breaking "same app" identity.

Play Integrity

Play Integrity is the replacement for SafetyNet Attestation (retired by 2025). The client requests a token from the Play API; the API returns a JWS-signed verdict; the backend verifies the signature before honoring a sensitive request. Three axes of verdict:

  • Device integrity - MEETS_BASIC_INTEGRITY, MEETS_DEVICE_INTEGRITY, MEETS_STRONG_INTEGRITY. Basic = looks plausible; Device = a Play-certified device that passes Play Protect; Strong = the verdict is backed by hardware key attestation, so a software-only impersonator cannot forge it.
  • App integrity - was this binary signed with your Play deployment key, installed via Play, unmodified.
  • Account details - is the calling Play account licensed for this app (paid app entitlement, etc).
val request = StandardIntegrityManager.PrepareIntegrityTokenRequest.builder()
    .setCloudProjectNumber(123456789L).build()
manager.prepareIntegrityToken(request).addOnSuccessListener { provider ->
    provider.request(StandardIntegrityTokenRequest.builder()
        .setRequestHash(serverChallenge).build())
        .addOnSuccessListener { token -> send(token.token()) }
}

The requestHash is the anti-replay primitive: a server-issued nonce the client includes in the token request, which the backend re-verifies. Without it, an attacker captures a legitimate token from a clean device and replays it from a rooted one. The verifier is the backend, not the client; a client that "checks integrity itself" gives up the entire guarantee.

Use it for payment / wallet flows, DRM-gated content, high-value rewarded ads, anti-cheat, account-creation throttling. Don't use it for ordinary login - it has quotas, adds latency, and a hostile network can withhold the token, so the design has to handle a "no verdict" path anyway.

See also

  • Network Protocols - TLS framing and version differences; pinning sits on top of an already-validated TLS chain.
  • Persistence - where EncryptedSharedPreferences, Tink-wrapped values, and DataStore decisions live alongside Room.
  • IPC - URI permission grants, FileProvider, and RECEIVER_NOT_EXPORTED are the cross-app threat-model primitives introduced there.
  • Platform Knowledge - app sandbox UIDs, Binder identity (getCallingUid), and the runtime substrate that the security primitives layer on.
  • WebView and Custom Tabs - JS-interface threat model, Chrome Custom Tabs, when a Trusted Web Activity replaces an embedded WebView.

Used in

  • Messenger App - auth token in a Keystore-wrapped store; certificate pinning on the API host; BiometricPrompt gates message export. End-to-end message encryption is a separate layer above transport TLS.
  • Crash Reporter SDK - the upload endpoint is pinned; the bearer token used to authenticate uploads is encrypted at rest; PII redaction runs before any payload leaves the device.
  • Networking Client - SPKI hash pinning, backup pins, and report-only rollout are the design moves; this page is the substrate the design assumes.
  • Photo Gallery App - scoped storage and MediaStore are how the picker walks the user's library; FileProvider is how exported renders cross to other apps.
  • Feature Flag SDK - Play Integrity verdicts gate experimental kill-switches in regulated builds; the config-download channel is pinned to prevent flag injection by a network adversary.

Done reading? Mark it so it sticks in your dashboard.