Lesson 35 of 83 advanced

Security: JWT, Secure Storage, Encryption & Biometrics

Build Android apps that protect user data at every layer

Open interactive version (quiz + challenge)

Real-world analogy

JWT is like a wristband at a festival — the bouncer stamps it at the gate (server issues token), and every booth checks it without calling the gate (stateless validation). EncryptedSharedPreferences is a lockbox inside a lockbox — Android Keystore holds the key, and nobody but your app (and the OS) can open it. BiometricPrompt is a fingerprint on a safe — the hardware scanner verifies identity, never exposing the biometric data to your app code.

What is it?

Android security spans multiple layers: network security (JWT, certificate pinning, HTTPS), data at rest (EncryptedSharedPreferences, Keystore), data in use (BiometricPrompt with CryptoObject), and code protection (ProGuard/R8). Senior engineers are expected to know not just how to implement each but why each layer is necessary and what attacks it mitigates.

Real-world relevance

In Payback fintech app, the JWT was stored in EncryptedSharedPreferences backed by AES256_GCM. The OkHttp Authenticator handled 401 token refresh with a Mutex to prevent race conditions. Certificate pinning was applied to the payment endpoint. BiometricPrompt unlocked a Keystore-backed AES key that decrypted the locally cached balance — even a rooted device couldn't read the balance without passing biometric auth.

Key points

Code example

// 1. EncryptedSharedPreferences — secure token storage
fun createSecurePrefs(context: Context): SharedPreferences {
    val masterKey = MasterKey.Builder(context)
        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
        .build()
    return EncryptedSharedPreferences.create(
        context,
        "secure_prefs",
        masterKey,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )
}

fun saveToken(context: Context, token: String) {
    createSecurePrefs(context).edit().putString("access_token", token).apply()
}

// 2. OkHttp Authenticator — thread-safe token refresh
class TokenAuthenticator(
    private val tokenRepo: TokenRepository
) : Authenticator {
    private val mutex = Mutex()

    override fun authenticate(route: Route?, response: Response): Request? {
        // Avoid infinite loop if refresh itself returns 401
        if (response.request.header("Authorization")?.contains("refresh") == true) return null

        val newToken = runBlocking {
            mutex.withLock {
                // Check if another coroutine already refreshed
                val current = tokenRepo.getAccessToken()
                if (current != response.request.header("Authorization")?.removePrefix("Bearer ")) {
                    current // Already refreshed by another thread
                } else {
                    tokenRepo.refreshToken() // Perform the actual refresh
                }
            }
        } ?: return null // Refresh failed — propagate 401

        return response.request.newBuilder()
            .header("Authorization", "Bearer $newToken")
            .build()
    }
}

// 3. BiometricPrompt with CryptoObject
class BiometricAuthManager(private val activity: FragmentActivity) {

    fun authenticate(onSuccess: (BiometricPrompt.AuthenticationResult) -> Unit, onError: (String) -> Unit) {
        val biometricManager = BiometricManager.from(activity)
        if (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)
            != BiometricManager.BIOMETRIC_SUCCESS) {
            onError("Biometric not available")
            return
        }
        val cipher = getOrCreateKeyStoreCipher() // Keystore-backed AES cipher

        val prompt = BiometricPrompt(activity, ContextCompat.getMainExecutor(activity),
            object : BiometricPrompt.AuthenticationCallback() {
                override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                    onSuccess(result) // result.cryptoObject?.cipher can decrypt data
                }
                override fun onAuthenticationError(code: Int, msg: CharSequence) {
                    onError(msg.toString())
                }
                override fun onAuthenticationFailed() {
                    // Called for non-matching biometric — user can retry
                }
            })

        val info = BiometricPrompt.PromptInfo.Builder()
            .setTitle("Authenticate to access balance")
            .setSubtitle("Use your fingerprint or face")
            .setNegativeButtonText("Use PIN instead")
            .build()

        prompt.authenticate(info, BiometricPrompt.CryptoObject(cipher))
    }

    private fun getOrCreateKeyStoreCipher(): Cipher {
        val keyStore = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) }
        if (!keyStore.containsAlias("balance_key")) {
            KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply {
                init(KeyGenParameterSpec.Builder("balance_key",
                    KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
                    .setBlockModes(KeyProperties.BLOCK_MODE_CBC)
                    .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7)
                    .setUserAuthenticationRequired(true)
                    .build())
                generateKey()
            }
        }
        val key = keyStore.getKey("balance_key", null) as SecretKey
        return Cipher.getInstance("AES/CBC/PKCS7Padding").also { it.init(Cipher.ENCRYPT_MODE, key) }
    }
}

// 4. Certificate pinning
val okHttpClient = OkHttpClient.Builder()
    .certificatePinner(
        CertificatePinner.Builder()
            .add("api.payback.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
            .add("api.payback.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") // backup pin
            .build()
    )
    .build()

Line-by-line walkthrough

  1. 1. MasterKey with AES256_GCM creates a hardware-backed master key in the Android Keystore — this key is used to encrypt all data in the EncryptedSharedPreferences file.
  2. 2. AES256_SIV encrypts the preference keys (so an attacker can't even see what keys you store). AES256_GCM encrypts the values with authentication.
  3. 3. The Mutex.withLock() in the Authenticator ensures only one coroutine performs the token refresh — others wait and then use the freshly refreshed token.
  4. 4. Comparing current token with the one in the response's Authorization header detects if another coroutine already refreshed — prevents redundant refresh calls.
  5. 5. Returning null from authenticate() tells OkHttp to stop retrying and propagate the 401 to the caller — critical for avoiding infinite refresh loops.
  6. 6. BiometricManager.canAuthenticate(BIOMETRIC_STRONG) checks hardware availability and enrollment — always check before showing the biometric option.
  7. 7. KeyGenParameterSpec with setUserAuthenticationRequired(true) binds the key to biometric authentication — the Keystore refuses to use the key without recent biometric auth.
  8. 8. BiometricPrompt.CryptoObject(cipher) ties the authentication result to the cipher — on success, result.cryptoObject?.cipher is initialized and ready to encrypt/decrypt.
  9. 9. KeyStore.getInstance("AndroidKeyStore").load(null) loads the hardware-backed keystore — null means no password is needed, as the Keystore is protected by the device secure hardware.
  10. 10. CertificatePinner.add() with two pins provides a primary and backup — if the server rotates certificates, the backup pin prevents app breakage.
  11. 11. The sha256/ prefix in CertificatePinner specifies the pin is a SHA-256 hash of the certificate's SubjectPublicKeyInfo — not the full certificate.
  12. 12. minifyEnabled = true enables R8 — it shrinks, optimizes, and obfuscates in a single pass, making reverse engineering significantly harder.

Spot the bug

// Bug-ridden authentication implementation
object TokenStore {
    // Bug 1 — insecure storage
    private val prefs = PreferenceManager.getDefaultSharedPreferences(context)

    fun saveToken(token: String) {
        prefs.edit().putString("auth_token", token).apply()
    }
}

class ApiAuthenticator(private val tokenRepo: TokenRepository) : Authenticator {
    override fun authenticate(route: Route?, response: Response): Request? {
        // Bug 2 — no loop prevention
        val newToken = runBlocking { tokenRepo.refreshToken() }
        return response.request.newBuilder()
            .header("Authorization", "Bearer $newToken")
            .build()
    }
}

fun setupBiometric(activity: FragmentActivity) {
    val prompt = BiometricPrompt(activity, ContextCompat.getMainExecutor(activity),
        object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                unlockApp()  // Bug 3 — no CryptoObject
            }
        })
    val info = BiometricPrompt.PromptInfo.Builder()
        .setTitle("Authenticate")
        .build()  // Bug 4
    prompt.authenticate(info)
}

val client = OkHttpClient.Builder()
    .addInterceptor { chain ->  // Bug 5 — wrong hook for refresh
        val response = chain.proceed(chain.request())
        if (response.code == 401) {
            val newToken = runBlocking { tokenRepo.refreshToken() }
            chain.proceed(chain.request().newBuilder()
                .header("Authorization", "Bearer $newToken").build())
        } else response
    }.build()
Need a hint?
Check storage security, infinite loop prevention in Authenticator, CryptoObject usage in BiometricPrompt, PromptInfo required fields, and the correct OkHttp hook for token refresh.
Show answer
Bug 1: PreferenceManager.getDefaultSharedPreferences() stores data in plain XML — readable by anyone with root access or physical device access. Auth tokens stored here are a textbook OWASP M9 (Insecure Data Storage) violation. Fix: use EncryptedSharedPreferences with a MasterKey backed by Android Keystore. Bug 2: The Authenticator has no loop prevention — if refreshToken() itself returns a 401 (e.g., refresh token is expired), authenticate() is called again infinitely. Fix: check if the failing request was already a refresh attempt (e.g., check a header or URL) and return null to stop the loop. Also add Mutex to prevent concurrent refresh calls. Bug 3: BiometricPrompt is used as a simple UI gate with no CryptoObject — a sophisticated attacker could bypass the UI check. The cryptographic operation (decrypting sensitive data) is not tied to the biometric auth result. Fix: create a Keystore-backed cipher and pass it as BiometricPrompt.CryptoObject — use result.cryptoObject?.cipher to perform the actual decryption only after successful auth. Bug 4: PromptInfo is missing setNegativeButtonText() — BiometricPrompt requires either a negative button (for non-device-credential fallback) or setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL). Without this, authenticate() throws an IllegalArgumentException. Fix: add .setNegativeButtonText("Use PIN instead") to the PromptInfo builder. Bug 5: Using an Interceptor for 401 token refresh is incorrect — Interceptors were designed for request/response transformation, not for retry-with-new-credentials flows. The Interceptor approach can also interfere with other interceptors and doesn't integrate with OkHttp's retry machinery. Fix: use OkHttpClient.Builder().authenticator(TokenAuthenticator()) — OkHttp's Authenticator is the correct, purpose-built hook for responding to 401 challenges.

Explain like I'm 5

JWT is like a wristband you get at a concert — security checks it at the door (server creates it) and bouncers inside just glance at it (your app checks the stamp) without calling the door every time. EncryptedSharedPreferences is a diary with a combination lock where only your phone's special chip knows the combination — even if someone steals the diary, they can't read it. BiometricPrompt is putting your fingerprint on a safe — your fingerprint never leaves the safe scanner, it just unlocks it.

Fun fact

Android Keystore keys marked with setUserAuthenticationRequired(true) use a hardware-level binding. When the user changes their screen lock PIN or removes biometrics, the key becomes permanently invalid — the app can never decrypt data encrypted with that key again. This is intentional security behavior, not a bug — it's why secure apps must handle key invalidation and re-encrypt data after biometric enrollment changes.

Hands-on challenge

Build a secure token management system: (1) EncryptedSharedPreferences wrapper with save/get/clear methods for access token, refresh token, and expiry timestamp; (2) An OkHttp Authenticator that reads the current token, checks if it was already refreshed by another concurrent request (compare with response's Authorization header), refreshes if needed using a Mutex to prevent race conditions, and returns null (propagates 401) if refresh fails; (3) A BiometricAuth class that checks availability, creates or retrieves a Keystore-backed AES key with user authentication required, presents BiometricPrompt with a CryptoObject, and on success uses the cipher to decrypt a locally stored encrypted payload; (4) Certificate pinning on your OkHttpClient with a primary and backup pin.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery