
Secure Defaults for Mobile SDKs
Series
Mobile SDK Design
6 of 7 in the series
A series about designing mobile SDKs that are easy to adopt, clear to integrate, and safe under production constraints.
Article 1
Designing a Mobile SDK That Developers Actually Want to Use
Article 2
The Public API Surface Is the Product
Article 3
Initialization, State, and Thread Safety in Mobile SDKs
Article 4
Developers Do Not Mind Errors. They Mind Unclear Errors.
Article 5
Shipping the SDK Is Where Trust Starts
Article 6
Secure Defaults for Mobile SDKs
Article 7
Same Concepts, Native Experience
Mobile clients run in an untrusted environment. An SDK that requires integrators to opt into safe behavior is one that leaves most of them in an insecure state.
Unsafe Defaults is the failure mode where the SDK makes insecure behavior easy to adopt accidentally, or requires integrators to take explicit steps to achieve basic safety rather than making those steps unnecessary.
The core principle: mobile clients run in an untrusted environment. The device may be rooted or jailbroken. Network traffic can be intercepted. The app process can be inspected at runtime on a compromised device. An SDK that ignores this does not only expose itself. It makes the host app less safe, often without the integrator knowing.
Covered here: token handling and storage, secure storage APIs on Android and iOS, PII minimization, consent-aware analytics, TLS and network trust defaults, and what the SDK should never ask the host app to store or do.
Not covered here: full certificate pinning strategy, device binding, root and jailbreak detection, payment-grade threat modeling, and regulatory compliance guidance. These topics require deeper treatment than a defaults article can responsibly provide.
The Design Question for Every Default
Every configuration the SDK ships with that a developer has not explicitly changed is a default. For security-relevant defaults, the question to ask is:
If an integrator follows the README without reading the security notes, what state are they in?
An SDK where the secure configuration is the default, and an insecure configuration requires an explicit override, protects developers who did not read the security section. An SDK where unsafe behavior requires no action and safe behavior requires configuration work will have a large fraction of integrators in an insecure state indefinitely.
Token Handling
API keys, session tokens, refresh tokens, and device tokens are the most common sensitive values in SDK integrations. Their handling in the SDK should not require any special action from the integrator.
What the SDK must not do:
Log tokens at any log level. Debug logs appear in crash reports, log aggregators, and CI build outputs. Even a partial token in a log line is a security exposure.
Accept tokens as URL query parameters. Query parameters appear in server access logs, network proxies, and browser history. Any token that appears in a URL path or query string is a token the SDK cannot protect.
Store tokens in SharedPreferences on Android or UserDefaults on iOS without encryption. Both are readable by other processes on rooted or jailbroken devices.
Expose token values in exception messages, stack traces, or error metadata. The fourth article in this series covers this in the context of crash reporter safety.
What the SDK should do:
Prefer short-lived, SDK-scoped credentials from the host app's backend rather than accepting and storing general app session tokens.
Persist tokens only when the SDK genuinely needs to survive process death or perform background work, and document why persistence is required. When persistence is necessary, store credentials in secure storage immediately and do not hold them in memory longer than required.
Transmit tokens only in HTTP request headers.
If the SDK owns the credential lifecycle, refresh SDK-scoped tokens internally before they expire. If the host app owns auth, expose a typed refresh-required error or callback instead of hiding the failure.
// Bad: token logged during configuration
fun configure(apiKey: String) {
Log.d("SDK", "Configuring with apiKey=$apiKey")
tokenStore.save(apiKey)
}
// Good: configuration is acknowledged, not echoed
fun configure(apiKey: String) {
tokenStore.save(apiKey)
Log.d("SDK", "configure() called [correlationId=$correlationId]")
}Secure Storage
Android: SharedPreferences is not an appropriate store for tokens or credentials. The default SharedPreferences file is accessible to other processes on rooted devices and to backup tools on unrooted devices.
For new SDKs, use the Android Keystore directly. Generate a key in the Keystore and use it to encrypt any token material the SDK needs to persist:
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
keyGenerator.init(
KeyGenParameterSpec.Builder(
"sdk_token_key",
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build()
)
val secretKey = keyGenerator.generateKey()The encrypted payload must include the IV/nonce required for AES-GCM decryption. Rely on reviewed crypto primitives rather than custom encryption formats for storing the ciphertext.
Store the encrypted result in app-private storage (context.filesDir or a private database). Exclude the token store from Auto Backup using backup or data extraction rules referenced from the app manifest. Auto Backup can copy app-private files to cloud storage and restore them onto different devices where the Keystore key is absent, which can make token decryption fail after restore.
EncryptedSharedPreferences from the Jetpack security-crypto library is deprecated as of version 1.1.0. If the SDK targets environments with existing EncryptedSharedPreferences usage, maintain compatibility, but do not introduce new dependencies on it for fresh integrations.
iOS: UserDefaults is not encrypted. Tokens, credentials, and session identifiers that need to persist across app launches should go in the Keychain.
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.example.sdk",
kSecAttrAccount as String: "auth_token",
kSecValueData as String: token.data(using: .utf8)!,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
SecItemAdd(query as CFDictionary, nil)Choose the accessibility constant based on when the SDK needs the item. Use kSecAttrAccessibleWhenUnlockedThisDeviceOnly when the SDK only needs the token while the user is actively using the app. Use kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly only when the SDK must access the token after a device reboot before the first unlock, such as for SDKs that perform background work. Both constants exclude iCloud backup and device migration. Neither is universally correct.
PII Minimization
An SDK that collects more user data than it needs creates legal and reputational liability for every host app that integrates it. The default should be to collect nothing beyond what the SDK's stated function requires.
Practical guidelines:
- Do not collect device advertising identifiers (IDFA on iOS, Android Advertising ID) unless the SDK's function requires them and the host app has obtained the required user consent, including App Tracking Transparency permission on iOS.
- Do not associate analytics events with persistent user identifiers until the host app explicitly passes a user ID to
configure(). - Allow the host app to configure the SDK with analytics disabled, even if analytics is the SDK's primary function. The analytics should not activate until the host app explicitly enables it.
- Document exactly what the SDK collects, what it sends to the backend, how long it is retained, and under what conditions it is deleted.
The practical test: remove the SDK from a host app and compare the data that reaches the SDK's backend before and after. If the SDK collected data that it cannot explain with a concrete function, that data should not be collected.
Consent-Aware Analytics
An SDK that begins tracking events before the host app has confirmed user consent creates legal exposure for the integrator in most jurisdictions. The safe default is no event tracking until the host app explicitly enables it.
// configure() with analytics disabled by default
SDK.configure {
userId = auth.currentUser?.id
analyticsEnabled = userPreferences.analyticsConsent // must be explicitly true
}If analyticsEnabled is not set, the SDK does not track. Not "track with reduced identifiers." Not "buffer events and send them when consent arrives." Not track at all until enabled.
The host app needs a way to respond to consent changes at runtime without reinitializing the SDK:
// Toggle analytics when user changes consent preference
preferencesManager.onAnalyticsConsentChanged { enabled ->
SDK.setAnalyticsEnabled(enabled)
}The SDK must handle being toggled off mid-session: discard any buffered events, stop new event collection, and wait for the next explicit setAnalyticsEnabled(true) call before resuming.
TLS and Network Trust
The SDK should not modify the host app's network trust configuration.
The most dangerous pattern in older SDKs is overriding the global SSLContext or installing a custom TrustManager to trust the SDK's backend. This pattern disables certificate validation for every HTTPS connection the app makes, not just the SDK's connections:
// Never do this in an SDK: it disables certificate validation app-wide
val trustAllCerts = arrayOf<TrustManager>(object : X509TrustManager {
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {}
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
})
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, trustAllCerts, SecureRandom())
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory)If the SDK's backend requires pinning or a custom certificate chain, scope that configuration to a dedicated HTTP client instance. Never apply it globally.
// Right: trust configuration scoped to the SDK's own client
private val sdkHttpClient = OkHttpClient.Builder()
.certificatePinner(
CertificatePinner.Builder()
.add("api.example.com", "sha256/...")
.build()
)
.build()The same principle applies on iOS: do not install a custom URLSession delegate that bypasses URLAuthenticationChallenge certificate validation globally. Use a dedicated URLSession configuration scoped to the SDK's own session.
The SDK should also not add cleartext domains to the host app's NetworkSecurityConfig on Android, and should not require the host app to disable App Transport Security (ATS) on iOS. If the SDK's backend requires HTTP in any configuration, that is a backend problem to fix, not a host-app security policy to loosen.
What the SDK Should Never Ask the Host App to Store
Some SDK configurations require values that the SDK cannot provision itself: API keys, client secrets, and other credentials that identify the integration. These must come from the host app.
The SDK documentation should be explicit about where these values should come from and what format they should take. It should equally be explicit about where they should not come from.
Never recommend storing credentials in:
strings.xmlorInfo.plist. These files are compiled into the app binary and can be extracted from an APK or IPA with standard tools without any decompilation.- Source code, as string literals or constants. Any string literal in source code is a string in version control, in build artifacts, and in the compiled binary.
BuildConfigfields that end up in the app manifest or bytecode as human-readable strings.
What to recommend instead:
- Credentials delivered at runtime from a secure backend that the app authenticates to. The app requests a short-lived SDK credential rather than storing a long-lived one.
- Build-time injection through a secrets management system keeps credentials out of source control, but any value that reaches runtime is still embedded in the app binary and is potentially recoverable by a motivated attacker. Build-time injection is acceptable for public client IDs, environment names, and non-sensitive configuration. It does not make long-lived secrets safe to ship in a mobile app.
- If a long-lived credential must be embedded, document why it cannot be avoided and what the blast radius is if it is compromised.
A Secure Defaults Checklist
Before shipping, validate the SDK's security defaults against these questions:
- Are tokens and credentials stored using Android Keystore-backed encrypted storage (Android) or Keychain (iOS)?
- Does the SDK define whether token refresh is SDK-owned or host-app-owned, with a typed refresh-required path when the host app owns auth?
- Do SDK log statements log correlation IDs and error codes rather than token values or user identifiers?
- Is analytics collection off by default until the host app explicitly enables it?
- Can the host app disable analytics at runtime without reinitializing the SDK?
- Does the SDK avoid installing any global
TrustManager,SSLContextoverride, or ATS exception? - Are the SDK's network requests scoped to a dedicated HTTP client instance with any custom trust configuration applied only there?
- Does the SDK collect only the data its function requires, with explicit opt-in for any optional collection?
- Does the documentation explain where credentials should come from, not just how to pass them?
- Does the documentation explicitly state not to store credentials in
strings.xml,Info.plist, or source code?