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
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
- Requirements gathering — fintech specifics — Reference apps: Payback (loyalty points and payment), TapMeHome (transport payment and expense claims). Requirements: 50K users, JWT + biometric auth, encrypted local storage for sensitive data, document upload for claims (photos, PDFs), claim status tracking pipeline (SUBMITTED -> UNDER_REVIEW -> APPROVED/REJECTED -> PAID), real-time status updates via FCM, compliance requirement: all auth events logged with timestamp and device fingerprint, session timeout after 5 minutes idle.
- Security architecture layer 1 — JWT + refresh token — Access token (JWT, 15-minute TTL) stored in memory only — never in SharedPreferences or file. Refresh token (30-day TTL) stored in EncryptedSharedPreferences (Jetpack Security). On app launch: check refresh token validity, exchange for new access token silently. On 401 response: trigger token refresh flow. On refresh token expiry: force logout. This 2-token pattern balances security and UX — short-lived access tokens limit exposure window.
- Security architecture layer 2 — biometric auth — BiometricPrompt with CryptoObject binds biometrics to cryptographic operations. Pattern: generate a secret key in AndroidKeyStore tied to biometric authentication (setUserAuthenticationRequired(true)). On successful biometric prompt, the key becomes available. Use it to decrypt the locally stored refresh token or to sign a server challenge. The key never leaves the secure hardware — even a rooted device cannot extract it.
- EncryptedSharedPreferences and EncryptedFile — Jetpack Security's EncryptedSharedPreferences wraps SharedPreferences with AES-256-GCM encryption using a master key stored in AndroidKeyStore. Use for: refresh token, user ID, cached sensitive profile fields. For sensitive files (claim documents before upload): use EncryptedFile with AES-256-GCM. Never store plaintext tokens, PII, or financial data in regular SharedPreferences, Room without encryption, or external storage.
- Auth interceptor — the OkHttp layer — Add an OkHttp Authenticator (not Interceptor) for 401 handling: on 401 response, attempt token refresh, retry the original request with new token. Add an Interceptor that attaches Authorization: Bearer {accessToken} to every request. If refresh also returns 401, clear all tokens and navigate to login. Crucially: use synchronized{} or a Mutex around the refresh call to prevent multiple parallel requests all triggering refresh simultaneously (refresh token race condition).
- Claims status tracking pipeline — Claim entity: ClaimId, userId, type, amount, status(DRAFT/SUBMITTED/UNDER_REVIEW/APPROVED/REJECTED/PAID), submittedAt, reviewedAt, paidAt, rejectionReason, documents[]. Status is a state machine — define valid transitions server-side and enforce them. Client tracks status via polling or FCM push. FCM notification on status change: 'Your claim #1234 has been approved — payment processing.' Tapping notification deep-links to the specific claim detail screen.
- Document upload architecture — Flow: 1) User selects document (photo via CameraX or PDF via Storage Access Framework). 2) Compress if image (Bitmap.compress to JPEG 80%). 3) Encrypt locally with EncryptedFile before storing in cache. 4) Upload via presigned S3 URL (server generates URL, client uploads directly to S3, avoiding the app server as a data proxy). 5) Server confirms receipt via webhook. 6) Client polls claim status or listens for FCM confirmation. 7) Delete local encrypted cache after confirmed upload.
- Error handling cascades — the fintech standard — Every network call must handle: NetworkException (no connectivity — show retry with offline indicator), HttpException 4xx (client error — parse error body, show specific message: 'Claim amount exceeds policy limit'), HttpException 5xx (server error — show generic retry, log to analytics), TimeoutException (show slow connection message with retry). Payment-specific: if payment initiation succeeds but confirmation times out, do NOT show error — show 'Processing' state and poll for status. Double-payment is worse than a pending state.
- Session timeout and idle detection — 5-minute idle timeout: record last user interaction timestamp. Use a LifecycleObserver that triggers a countdown when app goes to background. On foreground return after timeout: show biometric prompt before allowing access. Implementation: SharedFlow of user interaction events, collect in a ViewModel with a debounce-based timer. On timeout, emit SessionExpired event, ViewModel observes and navigates to lock screen. Never use Handler.postDelayed for this — it does not survive process death.
- Compliance: audit logging — Every auth event must be logged: login success/failure (with device fingerprint: model, OS version, app version), biometric success/failure, token refresh, logout, session timeout. Log locally to an append-only Room table (AuditLog: id, eventType, timestamp, deviceFingerprint, userId, metadata). Sync audit logs to server with high priority — even before syncing claims data. Logs must be tamper-evident: include a hash of the previous log entry (linked list pattern) so the server can detect if logs were deleted or modified.
- Root detection and certificate pinning — Root detection: use Google Play Integrity API (replacement for SafetyNet Attestation) to verify device integrity. On failed integrity check, show warning and optionally block sensitive operations. Certificate pinning: configure OkHttp CertificatePinner with your API server's certificate hash. Prevents man-in-the-middle attacks on compromised networks (common on public Wi-Fi). Include backup pins for certificate rotation. For critical financial transactions, require additional step-up auth (biometric re-verification).
- Interview narration strategy for fintech design — Structure: 1) Security architecture (auth flow + token storage) — 4 min. 2) Encrypted storage for sensitive data — 2 min. 3) Claims state machine with status tracking — 3 min. 4) Document upload with S3 presigned URLs — 2 min. 5) Error handling philosophy (processing > error for payments) — 2 min. 6) Compliance: audit logging, root detection, certificate pinning — 3 min. Key signal to interviewers: mention what you would NOT do (store JWT in SharedPreferences, show payment error on timeout, trust device root without Play Integrity) — negative knowledge is a strong senior indicator.
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. 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. 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. 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. 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. 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. 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. _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. 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. 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. 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?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- BiometricPrompt with CryptoObject — Android Developers (Android Docs)
- Jetpack Security — EncryptedSharedPreferences (Android Docs)
- OkHttp Authenticator for token refresh (OkHttp Docs)
- Google Play Integrity API (Android Docs)
- AndroidKeyStore system — Android Developers (Android Docs)