Layered Security Architecture for Mobile Banking

An in-depth look at the multi-layered security architecture that protects mobile banking apps, from device integrity checks and encrypted storage to runtime protection and network security.

business9 min readBy Klivvr Engineering
Share:

Security in mobile banking is not a single feature or a single technology. It is an architecture — a series of overlapping defensive layers, each designed to mitigate a specific class of threat, and collectively providing defense in depth. No single layer is impenetrable. A determined attacker with physical device access, time, and expertise can potentially defeat any individual control. The goal is to make the combined effort required to breach all layers impractical relative to the value of the target.

At Klivvr, our security architecture draws on standards from PCI DSS, OWASP Mobile Application Security, and the regulatory requirements of the financial jurisdictions in which we operate. This article walks through each layer of our defense-in-depth model, explains the threats it addresses, and shares the practical implementation choices that make each layer effective.

Layer 1: Device Integrity

The outermost layer verifies that the device itself has not been compromised. A jailbroken iOS device or a rooted Android device has weakened OS-level protections: an attacker (or malicious software) can bypass the app sandbox, intercept inter-process communication, or tamper with the app binary.

Jailbreak and root detection. On app launch, we perform a series of checks to determine whether the device has been tampered with:

On iOS, we check for common jailbreak indicators:

// DeviceIntegrityChecker.swift
class DeviceIntegrityChecker {
 
    func isDeviceCompromised() -> Bool {
        return checkSuspiciousFiles()
            || checkSuspiciousURLSchemes()
            || checkWriteAccessOutsideSandbox()
            || checkDynamicLibraries()
    }
 
    private func checkSuspiciousFiles() -> Bool {
        let suspiciousPaths = [
            "/Applications/Cydia.app",
            "/Library/MobileSubstrate/MobileSubstrate.dylib",
            "/bin/bash",
            "/usr/sbin/sshd",
            "/etc/apt",
            "/private/var/lib/apt/"
        ]
        return suspiciousPaths.contains { FileManager.default.fileExists(atPath: $0) }
    }
 
    private func checkSuspiciousURLSchemes() -> Bool {
        let schemes = ["cydia://", "sileo://", "zbra://"]
        return schemes.contains { scheme in
            UIApplication.shared.canOpenURL(URL(string: scheme)!)
        }
    }
 
    private func checkWriteAccessOutsideSandbox() -> Bool {
        let testPath = "/private/jailbreak_test_\(UUID().uuidString)"
        do {
            try "test".write(toFile: testPath, atomically: true, encoding: .utf8)
            try FileManager.default.removeItem(atPath: testPath)
            return true  // Write succeeded = sandbox is broken
        } catch {
            return false  // Write failed = sandbox is intact
        }
    }
 
    private func checkDynamicLibraries() -> Bool {
        let suspiciousLibs = ["SubstrateLoader", "TweakInject", "libhooker"]
        let count = _dyld_image_count()
        for i in 0..<count {
            if let name = _dyld_get_image_name(i) {
                let imageName = String(cString: name)
                if suspiciousLibs.contains(where: { imageName.contains($0) }) {
                    return true
                }
            }
        }
        return false
    }
}

On Android, we use Google's Play Integrity API (the successor to SafetyNet) to get a cryptographically signed attestation of device integrity:

// DeviceIntegrityChecker.kt
class DeviceIntegrityChecker(private val context: Context) {
 
    suspend fun checkIntegrity(): IntegrityResult {
        // Step 1: Request a nonce from our server
        val nonce = apiClient.requestIntegrityNonce()
 
        // Step 2: Request an integrity token from Google
        val integrityManager = IntegrityManagerFactory.create(context)
        val request = IntegrityTokenRequest.builder()
            .setNonce(nonce)
            .build()
 
        val tokenResponse = integrityManager.requestIntegrityToken(request).await()
 
        // Step 3: Send the token to our server for verification
        // The server decrypts and validates the token using Google's API
        return apiClient.verifyIntegrityToken(tokenResponse.token())
    }
 
    // Local checks as a supplementary layer
    fun performLocalRootChecks(): Boolean {
        return checkSuperUserBinaries()
            || checkBuildTags()
            || checkRootManagementApps()
    }
 
    private fun checkSuperUserBinaries(): Boolean {
        val paths = listOf(
            "/system/bin/su", "/system/xbin/su",
            "/sbin/su", "/data/local/xbin/su",
            "/data/local/bin/su"
        )
        return paths.any { File(it).exists() }
    }
 
    private fun checkBuildTags(): Boolean {
        return Build.TAGS?.contains("test-keys") == true
    }
 
    private fun checkRootManagementApps(): Boolean {
        val packages = listOf(
            "com.topjohnwu.magisk",
            "eu.chainfire.supersu",
            "com.koushikdutta.superuser"
        )
        return packages.any { pkg ->
            try {
                context.packageManager.getPackageInfo(pkg, 0)
                true
            } catch (e: PackageManager.NameNotFoundException) {
                false
            }
        }
    }
}

When a compromised device is detected, our policy is not to silently fail or crash the app. Instead, we inform the user that the device does not meet our security requirements and offer guidance: "This device appears to have been modified in a way that may compromise the security of your financial data. For your protection, some features are restricted." Critical operations (transfers, card management) are blocked; read-only access to account information may be permitted depending on the risk assessment.

Layer 2: App Integrity and Tamper Detection

Even on an uncompromised device, the app binary itself can be tampered with — repackaged with malicious code and distributed through unofficial channels. This layer ensures the running app is the genuine, unmodified version published by Klivvr.

Code signing verification. Both iOS and Android enforce code signing at the OS level, but additional runtime checks provide defense against sophisticated repackaging attacks.

On Android, we verify the app's signing certificate at runtime:

fun verifyAppSignature(context: Context): Boolean {
    val expectedSignatureHash = "your_expected_sha256_hash"
    val packageInfo = context.packageManager.getPackageInfo(
        context.packageName,
        PackageManager.GET_SIGNING_CERTIFICATES
    )
    val signatures = packageInfo.signingInfo.apkContentsSigners
    return signatures.any { signature ->
        val digest = MessageDigest.getInstance("SHA-256")
        val hash = digest.digest(signature.toByteArray())
        hash.toHexString() == expectedSignatureHash
    }
}

Debugger detection. Attaching a debugger to a running banking app allows an attacker to inspect memory, modify variables, and bypass security checks. We detect debugger attachment and respond accordingly:

func isDebuggerAttached() -> Bool {
    var info = kinfo_proc()
    var size = MemoryLayout<kinfo_proc>.stride
    var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
    let result = sysctl(&mib, UInt32(mib.count), &info, &size, nil, 0)
    guard result == 0 else { return false }
    return (info.kp_proc.p_flag & P_TRACED) != 0
}

Runtime method swizzling detection (iOS). On jailbroken devices, frameworks like Substrate or libhooker can replace method implementations at runtime. We detect common swizzling patterns on security-critical methods.

Layer 3: Data Protection at Rest

All sensitive data stored on the device must be encrypted and protected against unauthorized access.

Keychain (iOS) and Keystore (Android). Authentication tokens, encryption keys, and biometric-protected credentials are stored in hardware-backed secure storage. We covered these in detail in the biometric authentication article.

Database encryption. Our local SQLite database, which stores cached transaction history and account data, is encrypted using SQLCipher. The encryption key is derived from a master key stored in the Keychain/Keystore:

// DatabaseProvider.kt
fun createEncryptedDatabase(context: Context): KlivvrDatabase {
    val masterKey = getOrCreateMasterKey()
 
    val driver = AndroidSqliteDriver(
        schema = KlivvrDatabase.Schema,
        context = context,
        name = "klivvr.db",
        factory = SupportSQLiteOpenHelper.Factory {
            SupportFactory(masterKey.toByteArray())
        }
    )
 
    return KlivvrDatabase(driver)
}
 
private fun getOrCreateMasterKey(): String {
    val keyStore = KeyStore.getInstance("AndroidKeyStore")
    keyStore.load(null)
 
    if (!keyStore.containsAlias("db_master_key")) {
        generateMasterKey()
    }
 
    // Retrieve and decrypt the master key
    return decryptMasterKey(keyStore)
}

Sensitive data in memory. We minimize the time sensitive data (like full card numbers or PINs) spends in memory. When the data is no longer needed, we overwrite the memory location rather than relying on garbage collection. In Swift, Data values can be zeroed out explicitly:

extension Data {
    mutating func zeroOut() {
        self.withUnsafeMutableBytes { buffer in
            memset(buffer.baseAddress!, 0, buffer.count)
        }
    }
}

Screenshot prevention. When the app moves to the background, we overlay a branded splash screen to prevent the OS from capturing a screenshot of sensitive financial data for the task switcher:

// SceneDelegate.swift
func sceneDidEnterBackground(_ scene: UIScene) {
    let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
    blurView.frame = window?.bounds ?? .zero
    blurView.tag = 999
    window?.addSubview(blurView)
}
 
func sceneWillEnterForeground(_ scene: UIScene) {
    window?.viewWithTag(999)?.removeFromSuperview()
}

On Android:

// In Activity or Application class
window.setFlags(
    WindowManager.LayoutParams.FLAG_SECURE,
    WindowManager.LayoutParams.FLAG_SECURE
)

Layer 4: Network Security

Network security ensures that data in transit is protected from interception and modification. This layer includes:

TLS 1.2/1.3 enforcement. We reject connections using older TLS versions. On iOS, App Transport Security (ATS) enforces this by default. On Android, we configure the network security policy to require TLS 1.2+.

Certificate pinning. Covered in detail in our dedicated article on certificate pinning, this ensures the app only connects to servers presenting our specific cryptographic credentials.

Request signing. Every API request includes an HMAC signature computed from the request body, timestamp, and a client secret. The server validates this signature to ensure the request was not tampered with in transit and was generated by a genuine client.

fun signRequest(
    method: String,
    path: String,
    body: String,
    timestamp: Long,
    clientSecret: ByteArray
): String {
    val payload = "$method\n$path\n$timestamp\n$body"
    val mac = Mac.getInstance("HmacSHA256")
    mac.init(SecretKeySpec(clientSecret, "HmacSHA256"))
    return Base64.encodeToString(mac.doFinal(payload.toByteArray()), Base64.NO_WRAP)
}

Request replay prevention. Each request includes a timestamp and a unique nonce. The server rejects requests with timestamps more than 5 minutes old and maintains a short-lived cache of seen nonces to prevent replay attacks.

Layer 5: Application-Level Controls

The innermost layer consists of application-level security logic:

Session management. Sessions have a configurable timeout (default: 15 minutes of inactivity). After timeout, the user must re-authenticate with biometrics or PIN. Sensitive operations require step-up authentication regardless of session state.

Transaction velocity limits. Client-side checks prevent obviously suspicious patterns (e.g., multiple rapid transfers) before the request reaches the server. These are a UX courtesy, not a security boundary — the server enforces the authoritative limits.

Sensitive data masking. Account numbers and card numbers are masked by default in the UI (showing only the last four digits). The full number is revealed only on explicit request, protected by step-up authentication, and is automatically re-masked after 30 seconds.

Audit logging. Every security-relevant event — authentication attempts, biometric enrollments, permission changes, step-up authentications, detected integrity violations — is logged locally and synced to the server for monitoring and forensic analysis.

data class SecurityEvent(
    val type: SecurityEventType,
    val timestamp: Instant,
    val metadata: Map<String, String>,
    val deviceId: String,
    val appVersion: String,
    val sessionId: String?
)
 
enum class SecurityEventType {
    AUTH_BIOMETRIC_SUCCESS,
    AUTH_BIOMETRIC_FAILURE,
    AUTH_PIN_SUCCESS,
    AUTH_PIN_FAILURE,
    AUTH_STEP_UP_REQUIRED,
    DEVICE_INTEGRITY_CHECK_PASSED,
    DEVICE_INTEGRITY_CHECK_FAILED,
    CERTIFICATE_PINNING_FAILURE,
    SESSION_TIMEOUT,
    SENSITIVE_DATA_REVEALED,
    APP_BACKGROUNDED,
    APP_FOREGROUNDED
}

Security Testing and Validation

A layered security architecture is only as strong as the testing that validates it. Our security testing practices include:

  • Automated security scans run in CI on every pull request, checking for known vulnerabilities in dependencies, insecure API usage, and hardcoded secrets.
  • Annual penetration testing by an external security firm that specializes in mobile fintech applications.
  • Quarterly red team exercises where our internal security team attempts to bypass the layered controls.
  • OWASP MASTG compliance reviews against the Mobile Application Security Testing Guide, ensuring we meet or exceed the L2 verification level.

Conclusion

Layered security in mobile banking is about accepting that no single defense is perfect and building a system where each layer compensates for the weaknesses of the others. Device integrity checks catch compromised environments. App integrity checks catch tampered binaries. Data encryption protects stored information. Network security protects data in transit. Application-level controls manage sessions, transactions, and audit trails. Together, these layers create a security posture that is not merely compliant but genuinely resilient — worthy of the trust users place in an app that holds their financial lives.

Related Articles

business

Reducing Friction in Fintech User Onboarding

Strategies and technical approaches for streamlining fintech user onboarding, from identity verification and KYC to progressive profiling, while balancing regulatory compliance with conversion optimization.

11 min read
business

App Store Optimization for Fintech Products

A comprehensive guide to App Store Optimization (ASO) for fintech mobile apps, covering keyword strategy, creative optimization, ratings management, and the unique challenges of marketing a financial product in competitive app stores.

9 min read
technical

Building Offline-First Banking Experiences

How to architect a mobile banking app that remains functional without network connectivity, covering local data persistence, sync strategies, conflict resolution, and the UX patterns that make offline banking feel seamless.

9 min read