OWASP MASVS v2.0 checklist + MobSF (Mobile Security Framework) for automated scanning.| # | Category | Key Controls | iOS Implementation | Android Implementation |
|---|---|---|---|---|
| 1 | MASVS-STORAGE | Secure sensitive data at rest; no secrets in logs/backups/clipboard | Keychain with kSecAttrAccessibleWhenUnlockedThisDeviceOnly; Data Protection API | Android Keystore; EncryptedFile; exclude from backups via android:allowBackup="false" |
| 2 | MASVS-CRYPTO | Use approved algorithms; secure key management; no hardcoded keys | CommonCrypto/CryptoKit with AES-GCM; Secure Enclave for key storage | Android Keystore with setUserAuthenticationRequired(true); AES/GCM/NoPadding |
| 3 | MASVS-AUTH | Strong authentication; session management; biometric with fallback | LAContext with evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics); server-side token validation | BiometricPrompt with setNegativeButtonText for fallback; server-side session binding |
| 4 | MASVS-NETWORK | TLS 1.2+ enforced; certificate pinning; no cleartext traffic | App Transport Security (ATS) enforced by default; URLSession delegate for pinning | Network Security Config XML; cleartextTrafficPermitted="false"; pin-set with backup pins |
| 5 | MASVS-PLATFORM | Secure IPC; WebView hardening; input validation from deep links | Universal Links validation; WKWebView (never UIWebView); URL scheme input sanitization | Intent filter validation; WebView.setJavaScriptEnabled(false) by default; deep link validation |
| 6 | MASVS-CODE | Dependency management; no debug code in production; input validation | Strip debug symbols; disable logging in release; dependency audit | ProGuard/R8 obfuscation; BuildConfig.DEBUG checks; dependency scanning |
| 7 | MASVS-RESILIENCE | Anti-tampering; anti-debugging; integrity verification | App Attest API (DeviceCheck framework); ptrace detection; code signing validation | Play Integrity API (replaces SafetyNet); signature verification; Debug.isDebuggerConnected() checks |
| 8 | MASVS-PRIVACY | Data minimization; consent management; anonymization | ATT (App Tracking Transparency) framework; Privacy Manifest; on-device processing | Privacy Sandbox APIs; runtime permissions; PURPOSE_ declarations in manifest |
START: What is your app's risk profile?
├── Handles financial/health/government data?
│ ├── YES → Implement ALL 8 MASVS categories + certificate pinning + RASP + App Attest/Play Integrity
│ └── NO ↓
├── Handles user credentials or PII?
│ ├── YES → Implement MASVS-STORAGE + CRYPTO + AUTH + NETWORK + PLATFORM + CODE
│ └── NO ↓
├── Public data only, no user accounts?
│ ├── YES → Implement MASVS-NETWORK (TLS) + PLATFORM (WebView) + CODE (dependencies)
│ └── NO ↓
├── Which platform?
│ ├── iOS → Use Keychain + ATS + App Attest + CryptoKit
│ ├── Android → Use Keystore + Network Security Config + Play Integrity + Jetpack Security
│ └── Cross-platform → Apply native security on each side; DO NOT share crypto code
└── DEFAULT → Implement at minimum: MASVS-STORAGE + MASVS-NETWORK + MASVS-CODE
Store all sensitive data (tokens, credentials, PII) in platform-provided secure storage. Never use plaintext files, SharedPreferences, or UserDefaults for secrets. [src1]
// iOS: Keychain with device-only access
import Security
func saveToKeychain(key: String, data: Data) -> OSStatus {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
SecItemDelete(query as CFDictionary)
return SecItemAdd(query as CFDictionary, nil)
}
// Android: EncryptedSharedPreferences
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
val securePrefs = EncryptedSharedPreferences.create(
context, "secure_prefs", masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
securePrefs.edit().putString("auth_token", token).apply()
Verify: Run MobSF static analysis -- no findings under "Insecure Data Storage" category.
Pin your server's public key (SPKI hash) to prevent MITM attacks even with compromised CAs. Always include a backup pin. [src4]
<!-- Android: res/xml/network_security_config.xml -->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
<pin-set expiration="2027-01-01">
<pin digest="SHA-256">AAAA...primary-pin...AAAA=</pin>
<pin digest="SHA-256">BBBB...backup-pin....BBBB=</pin>
</pin-set>
</domain-config>
</network-security-config>
Verify: Use a proxy tool (Charles/mitmproxy) -- requests should fail when proxy's cert is injected.
Biometric auth must be tied to a server-side session, not just a local gate. A jailbroken device can bypass local-only biometric checks. [src3]
// iOS: LAContext biometric + Keychain-bound token
import LocalAuthentication
let context = LAContext()
if context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) {
context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Authenticate to access your account"
) { success, error in
if success {
let token = loadFromKeychain(key: "auth_token")
// Send token to server for validation
}
}
}
Verify: Attempt to access protected resources without biometric -- should be denied both locally and server-side.
WebViews are a major attack surface. Disable JavaScript unless required, validate all URLs, and never expose native bridges to untrusted content. [src2]
// Android: Secure WebView configuration
val webView = WebView(context)
webView.settings.apply {
javaScriptEnabled = false // Enable ONLY if required
allowFileAccess = false // Prevent file:// access
allowContentAccess = false // Prevent content:// access
domStorageEnabled = false
setGeolocationEnabled(false)
}
Verify: Attempt to load javascript:alert(1) or file:///etc/passwd -- both should be blocked.
Detect tampering, debugging, and compromised environments. Use platform attestation APIs for server-side verification. [src7]
// Android: Play Integrity API
val integrityManager = IntegrityManagerFactory.create(context)
val request = IntegrityTokenRequest.builder()
.setNonce(generateNonce()).build()
integrityManager.requestIntegrityToken(request)
.addOnSuccessListener { response ->
val token = response.integrityToken()
// Send to YOUR server for verification
}
Verify: Run on rooted/jailbroken device or emulator -- integrity check should fail.
import Security
import LocalAuthentication
// Input: Secret string to store securely
// Output: OSStatus indicating success/failure
func storeSecretWithBiometric(key: String, secret: String) -> OSStatus {
guard let data = secret.data(using: .utf8) else { return errSecParam }
let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryCurrentSet,
nil
)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessControl as String: access
]
SecItemDelete(query as CFDictionary)
return SecItemAdd(query as CFDictionary, nil)
}
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
// Input: Plaintext byte array
// Output: Encrypted data + IV pair
object SecureStorage {
private const val KEY_ALIAS = "app_secret_key"
fun generateKey() {
val gen = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
gen.init(KeyGenParameterSpec.Builder(KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true)
.setUserAuthenticationParameters(300,
KeyProperties.AUTH_BIOMETRIC_STRONG)
.build())
gen.generateKey()
}
}
// BAD -- plaintext storage readable on rooted devices
val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
prefs.edit().putString("auth_token", "eyJhbGciOiJIUzI1NiIs...").apply()
// Any app with root access can read /data/data/com.app/shared_prefs/
// GOOD -- encrypted at rest with Keystore-backed key
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
val securePrefs = EncryptedSharedPreferences.create(
context, "secure_prefs", masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
securePrefs.edit().putString("auth_token", token).apply()
// BAD -- disables ALL certificate validation
class InsecureDelegate: NSObject, URLSessionDelegate {
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
completionHandler(.useCredential,
URLCredential(trust: challenge.protectionSpace.serverTrust!))
}
}
// GOOD -- validates server certificate against pinned key hash
class PinnedDelegate: NSObject, URLSessionDelegate {
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard let trust = challenge.protectionSpace.serverTrust,
SecTrustEvaluateWithError(trust, nil),
validatePin(trust: trust) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
completionHandler(.useCredential, URLCredential(trust: trust))
}
}
// BAD -- tokens and PII visible in logcat
Log.d("Auth", "User token: $authToken")
Log.d("Payment", "Card number: ${card.number}")
// GOOD -- no sensitive data; disabled in release
if (BuildConfig.DEBUG) {
Log.d("Auth", "Token received: [REDACTED]")
}
// ProGuard rule: -assumenosideeffects class android.util.Log { *; }
// BAD -- extractable via strings command on binary
let apiKey = "sk_live_abc123xyz789"
// GOOD -- key stored in Keychain, never in source code
guard let apiKeyData = loadFromKeychain(key: "api_key"),
let apiKey = String(data: apiKeyData, encoding: .utf8) else {
fatalError("API key not provisioned")
}
android:allowBackup="false" or use android:fullBackupContent to exclude sensitive files. [src1]UIPasteboard with .localOnly: true and expiry on iOS; ClipboardManager with expiry on Android. [src2]applicationWillResignActive (iOS) or set FLAG_SECURE on the window (Android). [src2]kSecAttrAccessibleAlways makes Keychain data available when device is locked. Fix: Use kSecAttrAccessibleWhenUnlockedThisDeviceOnly for sensitive data. [src3]strings or disassemblers. Fix: Generate keys in Keystore/Keychain or derive via HKDF from server material. [src6]apktool d app.apk and check class/method names. [src7]# Static analysis with MobSF (Docker)
docker run -it --rm -p 8000:8000 opensecurity/mobile-security-framework-mobsf:latest
# Extract SPKI hash for certificate pinning
openssl s_client -connect api.example.com:443 2>/dev/null | \
openssl x509 -pubkey -noout | \
openssl pkey -pubin -outform DER | \
openssl dgst -sha256 -binary | \
openssl enc -base64
# Check APK for plaintext secrets
apktool d app.apk -o decoded && grep -rn "api_key\|secret\|password\|token" decoded/
# Verify iOS binary for hardcoded strings
strings MyApp.app/MyApp | grep -i "key\|secret\|password\|token"
# Check Android obfuscation
jadx -d output app.apk && grep -rn "class.*Activity" output/
# Verify network security config is applied
adb shell dumpsys package com.example.app | grep networkSecurityConfig
| Standard/Tool | Version | Status | Key Changes |
|---|---|---|---|
| OWASP MASVS | v2.1.0 | Current (2024) | Added MASVS-PRIVACY; removed L1/L2/R levels; MAS Testing Profiles |
| OWASP MASVS | v1.5.0 | Deprecated | Three verification levels (L1, L2, R); MSTG companion |
| Android Jetpack Security | 1.1.0-alpha06 | Current | EncryptedSharedPreferences + EncryptedFile |
| Play Integrity API | 1.3 | Current (2025) | Replaces SafetyNet Attestation (deprecated 2024) |
| Apple App Attest | iOS 14+ | Current | Device attestation; replaces DeviceCheck for assertions |
| Apple ATS | iOS 9+ | Enforced | TLS 1.2+ required by default |
| CryptoKit | iOS 13+ | Current | Modern Swift crypto API (AES-GCM, SHA-256, P-256) |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| App handles user credentials, PII, or payment data | Building a static content app with no user data | Basic HTTPS enforcement only |
| App communicates with backend APIs | App is entirely offline with no network calls | Focus on MASVS-STORAGE only |
| App processes financial or health data | Building an internal prototype/demo | Simplified security for development |
| App is distributed via public app stores | App is only used on managed/MDM devices | Enterprise MDM security policies |
| Regulatory compliance required (GDPR, HIPAA, PCI DSS) | No regulatory requirements apply | Risk-based subset of MASVS |
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly is used -- this can be a privacy concern