Security: JWT, Secure Storage, Encryption & Biometrics
Build Android apps that protect user data at every layer
Open interactive version (quiz + challenge)Real-world analogy
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
- JWT structure — Three base64url-encoded parts separated by dots: header.payload.signature. Header specifies algorithm (RS256, HS256). Payload contains claims (sub, iat, exp, custom fields). Signature is computed by the server — your Android app should NEVER trust a JWT without backend validation.
- JWT token lifecycle on Android — Store access token in EncryptedSharedPreferences (never plain SharedPreferences or plain files). Attach as Authorization: Bearer header. Check exp claim before requests and refresh proactively. Use an OkHttp Authenticator (not Interceptor) to refresh on 401.
- OkHttp Authenticator for token refresh — The Authenticator.authenticate() callback is called only on 401 responses — the right place to refresh the token and retry the original request. Use @Synchronized or a Mutex to prevent simultaneous refresh calls from multiple in-flight requests.
- EncryptedSharedPreferences — MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(). Then EncryptedSharedPreferences.create(context, fileName, masterKey, AES256_SIV, AES256_GCM). Both keys and values are encrypted — even if the device is rooted, the data is unreadable without the Keystore key.
- Android Keystore — Hardware-backed (TEE/StrongBox) key storage that never exposes the raw key material to the app process. Keys can be constrained: user authentication required, key expires after X seconds of non-use, hardware attestation. Use KeyPairGenerator or KeyGenerator with KeyGenParameterSpec.
- BiometricPrompt — BiometricPrompt(activity, executor, callback) with BiometricPrompt.PromptInfo. On success, authenticate() returns a CryptoObject that can be used to unlock a Keystore-backed cipher. This ties biometric authentication to a specific cryptographic operation — not just a UI gate.
- BiometricManager availability check — BiometricManager.from(context).canAuthenticate(BIOMETRIC_STRONG) returns: BIOMETRIC_SUCCESS (available), BIOMETRIC_ERROR_NO_HARDWARE, BIOMETRIC_ERROR_HW_UNAVAILABLE, BIOMETRIC_ERROR_NONE_ENROLLED. Always check before showing the biometric option.
- Certificate pinning with OkHttp — CertificatePinner.Builder().add("api.teamzlab.com", "sha256/AAAA...").build(). Attach to OkHttpClient.Builder(). Prevents MITM attacks even with compromised CA certificates. In Payback fintech, pinning was mandatory for payment API endpoints.
- ProGuard/R8 obfuscation — R8 (the modern replacement for ProGuard) minifies, optimizes, and obfuscates bytecode. Enable with minifyEnabled = true and shrinkResources = true in release build config. Add -keep rules for classes used via reflection (Gson models, Retrofit interfaces, Firebase).
- OWASP Mobile Top 10 — key items — M1: Improper Credential Usage (hardcoded keys). M2: Inadequate Supply Chain Security. M4: Insufficient Input/Output Validation. M8: Security Misconfiguration (cleartext traffic). M9: Insecure Data Storage (plain SharedPreferences for tokens). Know these for senior interview questions.
- Cleartext traffic prevention — Since Android 9, cleartext (HTTP) traffic is blocked by default. Ensure all API calls use HTTPS. For debug builds only, use a network security config XML to allow specific domains. Never allow cleartext in release builds — apps with cleartext traffic can be rejected from Play Store.
- Secure coding checklist — No API keys in source code (use BuildConfig from local.properties, not committed). No sensitive data in logs (Timber.d in debug only). No sensitive data in Activity extras or deep link URIs (use POST body or session reference). Validate all input on the server, not just the client.
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. 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. 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. The Mutex.withLock() in the Authenticator ensures only one coroutine performs the token refresh — others wait and then use the freshly refreshed token.
- 4. Comparing current token with the one in the response's Authorization header detects if another coroutine already refreshed — prevents redundant refresh calls.
- 5. Returning null from authenticate() tells OkHttp to stop retrying and propagate the 401 to the caller — critical for avoiding infinite refresh loops.
- 6. BiometricManager.canAuthenticate(BIOMETRIC_STRONG) checks hardware availability and enrollment — always check before showing the biometric option.
- 7. KeyGenParameterSpec with setUserAuthenticationRequired(true) binds the key to biometric authentication — the Keystore refuses to use the key without recent biometric auth.
- 8. BiometricPrompt.CryptoObject(cipher) ties the authentication result to the cipher — on success, result.cryptoObject?.cipher is initialized and ready to encrypt/decrypt.
- 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. CertificatePinner.add() with two pins provides a primary and backup — if the server rotates certificates, the backup pin prevents app breakage.
- 11. The sha256/ prefix in CertificatePinner specifies the pin is a SHA-256 hash of the certificate's SubjectPublicKeyInfo — not the full certificate.
- 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?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- EncryptedSharedPreferences — Security Library (Android Docs)
- Android Keystore System (Android Docs)
- BiometricPrompt Guide (Android Docs)
- OkHttp Certificate Pinning (OkHttp Docs)
- OWASP Mobile Top 10 (OWASP)