Lesson 60 of 83 advanced

System Design III: Secure Auth/Payment/Claims Flow

Architecting a fintech claims and payment app — security, encrypted storage, and audit trails

Open interactive version (quiz + challenge)

Real-world analogy

Designing a fintech claims app is like designing a bank vault with a transparent audit camera. Every door that opens must be logged. Every action must require two keys (biometric + server token). Every error must be handled gracefully — a crashed payment screen is not just a bug, it is a compliance incident. The vault must stay locked even if one guard falls asleep.

What is it?

Fintech claims and payment app system design is the highest-stakes Android architecture question. It combines JWT authentication with biometric binding, encrypted local storage, OkHttp auth interceptors with race-condition-safe token refresh, a document upload pipeline using presigned URLs, claims status machine tracking, compliant audit logging, and security hardening (root detection, certificate pinning). Every architectural decision has security and compliance implications.

Real-world relevance

Payback Bangladesh manages loyalty points and payment claims for retail customers — requiring secure token management, offline claim drafting, and status tracking. TapMeHome handles transport expense claims for corporate clients — requiring document upload (receipts), multi-step approval workflows, and financial audit trails. Both apps operate in Bangladesh's regulated fintech space under Bangladesh Bank guidelines, which mandate specific security and logging requirements.

Key points

Code example

// AndroidKeyStore + BiometricPrompt key binding
class BiometricKeyManager(private val context: Context) {
    private val keystore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
    private val keyAlias = "biometric_auth_key"

    fun generateKey() {
        if (keystore.containsAlias(keyAlias)) return

        val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
        keyGenerator.init(
            KeyGenParameterSpec.Builder(keyAlias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
                .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
                .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
                .setUserAuthenticationRequired(true)
                .setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
                .build()
        )
        keyGenerator.generateKey()
    }

    fun getCipher(): Cipher {
        val key = keystore.getKey(keyAlias, null) as SecretKey
        return Cipher.getInstance("AES/GCM/NoPadding").apply {
            init(Cipher.DECRYPT_MODE, key)
        }
    }
}

// BiometricPrompt with CryptoObject
class AuthManager(private val activity: FragmentActivity) {
    private val keyManager = BiometricKeyManager(activity)

    fun authenticateWithBiometric(onSuccess: (Cipher) -> Unit, onError: (String) -> Unit) {
        keyManager.generateKey()
        val cipher = keyManager.getCipher()

        val prompt = BiometricPrompt(activity, ContextCompat.getMainExecutor(activity),
            object : BiometricPrompt.AuthenticationCallback() {
                override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                    result.cryptoObject?.cipher?.let(onSuccess)
                }
                override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                    onError(errString.toString())
                }
                override fun onAuthenticationFailed() {
                    onError("Biometric not recognized")
                }
            }
        )
        val info = BiometricPrompt.PromptInfo.Builder()
            .setTitle("Verify your identity")
            .setSubtitle("Use biometrics to access your account")
            .setNegativeButtonText("Use PIN")
            .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
            .build()

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

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

    override fun authenticate(route: Route?, response: Response): Request? {
        if (response.request.header("Authorization") == null) return null

        val newToken = runBlocking {
            refreshMutex.withLock {
                val currentToken = tokenRepo.getAccessToken()
                if (currentToken != null && response.request.header("Authorization") == "Bearer $currentToken") {
                    tokenRepo.refreshToken()
                } else {
                    currentToken
                }
            }
        }
        return newToken?.let {
            response.request.newBuilder()
                .header("Authorization", "Bearer $it")
                .build()
        }
    }
}

// EncryptedSharedPreferences for token storage
class TokenRepository(context: Context) {
    private val masterKey = MasterKey.Builder(context)
        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
        .build()

    private val prefs = EncryptedSharedPreferences.create(
        context, "secure_prefs", masterKey,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )

    var refreshToken: String?
        get() = prefs.getString("refresh_token", null)
        set(value) = prefs.edit().putString("refresh_token", value).apply()

    // Access token stored in memory only
    private var _accessToken: String? = null
    fun getAccessToken() = _accessToken
    fun setAccessToken(token: String) { _accessToken = token }
    fun clearAccessToken() { _accessToken = null }

    suspend fun refreshToken(): String? {
        val refresh = refreshToken ?: run {
            clearAll()
            return null
        }
        return try {
            val response = AuthApi.create().refresh(RefreshRequest(refresh))
            setAccessToken(response.accessToken)
            if (response.refreshToken != null) refreshToken = response.refreshToken
            response.accessToken
        } catch (e: HttpException) {
            if (e.code() == 401) clearAll()
            null
        }
    }

    fun clearAll() {
        clearAccessToken()
        refreshToken = null
    }
}

// Claims state machine
enum class ClaimStatus {
    DRAFT, SUBMITTED, UNDER_REVIEW, APPROVED, REJECTED, PAID;

    fun validTransitions(): Set<ClaimStatus> = when (this) {
        DRAFT -> setOf(SUBMITTED)
        SUBMITTED -> setOf(UNDER_REVIEW)
        UNDER_REVIEW -> setOf(APPROVED, REJECTED)
        APPROVED -> setOf(PAID)
        REJECTED -> setOf(DRAFT)
        PAID -> emptySet()
    }

    fun canTransitionTo(next: ClaimStatus) = validTransitions().contains(next)
}

// Document upload via presigned S3 URL
class DocumentUploader(private val api: ClaimsApi) {

    suspend fun uploadDocument(file: File, claimId: String): String {
        // 1. Get presigned URL from server
        val presigned = api.getPresignedUrl(
            PresignedUrlRequest(claimId = claimId, fileName = file.name, contentType = "image/jpeg")
        )

        // 2. Upload directly to S3
        val requestBody = file.asRequestBody("image/jpeg".toMediaType())
        val response = OkHttpClient().newCall(
            Request.Builder()
                .url(presigned.uploadUrl)
                .put(requestBody)
                .build()
        ).execute()

        if (!response.isSuccessful) throw IOException("S3 upload failed: ${response.code}")

        // 3. Return the public CDN URL
        return presigned.publicUrl
    }
}

// Audit log with tamper-evident chaining
@Entity(tableName = "audit_logs")
data class AuditLog(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val eventType: String,
    val userId: String,
    val timestamp: Long = System.currentTimeMillis(),
    val deviceFingerprint: String,
    val metadata: String,
    val previousHash: String,
    val entryHash: String
)

class AuditLogger(private val db: AppDatabase, private val context: Context) {

    suspend fun log(eventType: String, userId: String, metadata: Map<String, String> = emptyMap()) {
        val lastLog = db.auditLogDao().getLastEntry()
        val fingerprint = "${Build.MODEL}|${Build.VERSION.SDK_INT}|${BuildConfig.VERSION_NAME}"
        val metaJson = metadata.entries.joinToString(",") { "${it.key}=${it.value}" }
        val previousHash = lastLog?.entryHash ?: "GENESIS"
        val content = "$eventType|$userId|${System.currentTimeMillis()}|$fingerprint|$metaJson|$previousHash"
        val entryHash = sha256(content)

        db.auditLogDao().insert(
            AuditLog(
                eventType = eventType,
                userId = userId,
                deviceFingerprint = fingerprint,
                metadata = metaJson,
                previousHash = previousHash,
                entryHash = entryHash
            )
        )
    }

    private fun sha256(input: String): String {
        val digest = MessageDigest.getInstance("SHA-256")
        return digest.digest(input.toByteArray()).joinToString("") { "%02x".format(it) }
    }
}

Line-by-line walkthrough

  1. 1. KeyGenParameterSpec with setUserAuthenticationRequired(true) and setUserAuthenticationParameters(0, AUTH_BIOMETRIC_STRONG) creates a key that is gated behind biometric — 0 seconds means the key becomes available only immediately after successful biometric, with no time window.
  2. 2. BiometricPrompt.CryptoObject(cipher) binds the biometric authentication event to the cryptographic operation — the system only unlocks the key if biometric succeeds, making it impossible to use the key programmatically without user presence.
  3. 3. TokenAuthenticator extends Authenticator (not Interceptor) — Authenticator is called only on 401 responses, while Interceptor is called on every request. Using Interceptor for auth would retry non-auth failures incorrectly.
  4. 4. refreshMutex.withLock{} ensures only one coroutine performs token refresh at a time — without this, 5 parallel requests all getting 401 would trigger 5 simultaneous refresh calls, likely revoking all tokens.
  5. 5. The condition 'if currentToken equals the token used in the failed request' prevents a second request from re-triggering refresh when another coroutine already refreshed — the token in memory is now different, so skip refresh.
  6. 6. EncryptedSharedPreferences is backed by AndroidKeyStore for its master key — unlike app-level encryption, the master key cannot be extracted even with root access because it lives in secure hardware.
  7. 7. _accessToken stored as a private in-memory var means it is cleared when the process is killed — this is intentional; access tokens should not survive process death, forcing a refresh (which requires the refresh token in EncryptedSharedPreferences).
  8. 8. ClaimStatus.canTransitionTo() enforces the state machine on the client — prevents UI bugs like double-submitting a claim. The server also enforces transitions, but client-side validation gives immediate feedback.
  9. 9. Presigned URL upload sends the file directly from the device to S3 — the server never touches the file bytes, dramatically reducing server bandwidth costs for a document-heavy claims app.
  10. 10. AuditLog.entryHash = sha256(content including previousHash) creates a cryptographic chain — server can verify integrity by recomputing hashes from GENESIS and checking every entry matches, detecting any modification or deletion.

Spot the bug

class TokenInterceptor(private val tokenRepo: TokenRepository) : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {
        val token = tokenRepo.getAccessToken()
        val request = chain.request().newBuilder()
            .header("Authorization", "Bearer $token")
            .build()
        val response = chain.proceed(request)

        if (response.code == 401) {
            val newToken = runBlocking { tokenRepo.refreshToken() }
            val retryRequest = chain.request().newBuilder()
                .header("Authorization", "Bearer $newToken")
                .build()
            return chain.proceed(retryRequest)
        }
        return response
    }
}
Need a hint?
There are three bugs: one causes infinite retry loops, one causes race conditions in multi-threaded scenarios, and one leaks a response body causing memory issues.
Show answer
Bug 1: When the 401 retry itself returns 401 (e.g., refresh token also expired), the interceptor calls chain.proceed() again, which fires another 401, which calls proceed again — an infinite retry loop causing a StackOverflowError or request loop. Fix: use OkHttp's Authenticator interface instead of Interceptor for auth retries — Authenticator has built-in protection against retry loops (it is called at most once per request). Bug 2: There is no Mutex around tokenRepo.refreshToken(). If 10 requests are in-flight and all get 401 simultaneously, 10 coroutines all call refreshToken() concurrently. The first call refreshes and gets a new token; the other 9 use the old refresh token which is now invalid, getting 401 again and potentially locking the user out. Fix: wrap with a Mutex so only one refresh runs at a time, and subsequent waiters use the token obtained by the first refresh. Bug 3: response.code is read but the original response body is not closed before calling chain.proceed() for the retry. In OkHttp, response bodies are resources that must be closed (response.close()) before proceeding, otherwise a resource leak accumulates and eventually causes connection pool exhaustion. Fix: call response.close() before the retry proceed() call.

Explain like I'm 5

Imagine a magic safe at the bank. To open it, you need to press your thumb on a scanner AND have a special code card. The scanner checks your thumb, and only then does the safe unlock — even if someone stole the code card, they cannot open the safe without your actual thumb. That is what biometric + AndroidKeyStore does. And every time you open or close the safe, a security camera records it with a unique code that proves the recording was not tampered with.

Fun fact

Bangladesh Bank's Payment Systems Department mandates that digital payment platforms maintain tamper-evident audit logs for a minimum of 5 years. This is why fintech apps in Bangladesh cannot simply use a regular log file — they need exactly the blockchain-inspired linked hash pattern shown in this lesson, implemented in a local Room database synced to secure server storage.

Hands-on challenge

Design the complete auth and claims flow for a fintech app with these security requirements: JWT + biometric binding, 5-minute idle timeout, tamper-evident audit logs, document upload with encryption at rest. Specify: (1) The exact token storage strategy — what goes in memory, EncryptedSharedPreferences, and AndroidKeyStore, and why. (2) The OkHttp Authenticator implementation with Mutex-protected refresh and the exact condition that triggers forced logout. (3) The complete claims status machine with all valid transitions and the FCM notification triggered at each transition. (4) The document upload flow from camera capture to confirmed server receipt, including the local encryption step before upload. (5) How you implement idle timeout without breaking it across process death. (6) What security checks you run at app launch (root detection, integrity check, certificate pinning test) and how you handle failures gracefully.

More resources

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