{"id":100026,"date":"2026-11-03T02:11:40","date_gmt":"2026-11-03T02:11:40","guid":{"rendered":"https:\/\/www.nandee.ai\/blog\/insecure-data-storage-android-ios\/"},"modified":"2026-11-06T18:39:19","modified_gmt":"2026-11-06T18:39:19","slug":"insecure-data-storage-android-ios","status":"publish","type":"post","link":"https:\/\/www.nandee.ai\/blog\/insecure-data-storage-android-ios\/","title":{"rendered":"Insecure Data Storage on Android and iOS: Detection and Fixes"},"content":{"rendered":"<p>The security questionnaire arrives two weeks before your enterprise deal closes. Question 14 reads: &#8220;Describe how authentication tokens and sensitive user data are stored on the device.&#8221; Your mobile lead opens the codebase to write an answer and finds the refresh token sitting in <code>UserDefaults<\/code> on iOS and in <code>AsyncStorage<\/code> in the React Native checkout module. Nobody wrote it that way on purpose. It was the fastest path during the MVP sprint, and it has shipped in every release since.<\/p>\n<p>This is one of the most common findings in mobile security reviews. It is also one of the easiest to fix once you know where to look. This guide covers why it happens, what the standards say, and how to detect and fix it on iOS, Android, Flutter, and React Native.<\/p>\n<h2>Why Insecure Data Storage Keeps Shipping<\/h2>\n<p>Every mobile platform gives developers a simple key-value store. On iOS it is <code>UserDefaults<\/code>. On Android it is <code>SharedPreferences<\/code>. In React Native it is <code>AsyncStorage<\/code>, and in Flutter it is the <code>shared_preferences<\/code> package. They have clean APIs, they work right away, and they are documented in every tutorial.<\/p>\n<p>None of them are meant for secrets.<\/p>\n<p><code>UserDefaults<\/code> writes a plist to the app&#8217;s <code>Library\/Preferences<\/code> directory. <code>SharedPreferences<\/code> writes an XML file to <code>\/data\/data\/&lt;package&gt;\/shared_prefs\/<\/code>. <code>AsyncStorage<\/code> stores data unencrypted, backed by SQLite or files depending on platform and version. All of these sit inside the app sandbox, and the sandbox is a real protection, but it is not encryption. A rooted or jailbroken device, a forensic extraction, a device backup, or a malicious app exploiting a separate vulnerability can all reach that data.<\/p>\n<p>Backups deserve special attention. On Android, Auto Backup includes <code>SharedPreferences<\/code> files by default unless you exclude them. On iOS, <code>UserDefaults<\/code> is included in device backups. A token that is &#8220;only on the device&#8221; may also be in a cloud backup or on a laptop.<\/p>\n<h2>What the Standards Say<\/h2>\n<p>OWASP MASVS v2 covers this under the MASVS-STORAGE control group. MASVS-STORAGE-1 requires that the app store sensitive data securely. MASVS-STORAGE-2 requires that it prevent sensitive data from leaking through logs, backups, the clipboard, and similar channels. The OWASP MASTG provides test procedures for both, including checks on local storage, backups, and logs on each platform. Insecure data storage is also M9 on the <a href=\"https:\/\/www.nandee.ai\/blog\/owasp-mobile-top-10\/\">OWASP Mobile Top 10 (2024)<\/a>.<\/p>\n<p>The relevant CWEs are:<\/p>\n<ul>\n<li>CWE-312: Cleartext Storage of Sensitive Information<\/li>\n<li>CWE-922: Insecure Storage of Sensitive Information<\/li>\n<li>CWE-532: Insertion of Sensitive Information into Log File<\/li>\n<\/ul>\n<p>If you are in fintech or healthcare, these map directly to the questions auditors and enterprise buyers ask. &#8220;Where are tokens stored, and are they protected by hardware-backed encryption?&#8221; is a question you want to answer in one sentence, not one week.<\/p>\n<h2>What Counts as Sensitive<\/h2>\n<p>Before fixing storage, decide what needs protecting. For most apps the list includes access and refresh tokens, session cookies, API keys issued to the user, PII (names, emails, account numbers, addresses), PHI in healthcare apps, and anything that would let an attacker impersonate a user or reconstruct their activity.<\/p>\n<p>Feature flags, UI preferences, and onboarding state can stay in the simple key-value store. The goal is not to encrypt everything. The goal is to put secrets where the platform&#8217;s hardware-backed protections apply.<\/p>\n<h2>Fixing It on iOS: Keychain, Not UserDefaults<\/h2>\n<p>Here is the pattern that shows up in reviews:<\/p>\n<pre><code class=\"language-swift\">\/\/ Vulnerable: CWE-312. Token stored in a plaintext plist that is included in backups.\nUserDefaults.standard.set(accessToken, forKey: \"access_token\")<\/code><\/pre>\n<p>The fix is to use the Keychain with an explicit accessibility class:<\/p>\n<pre><code class=\"language-swift\">import Foundation\nimport Security\n\nstruct KeychainError: Error { let status: OSStatus }\n\nenum TokenStore {\n    private static let service = Bundle.main.bundleIdentifier ?? \"app.auth\"\n\n    static func save(_ token: String, account: String = \"access_token\") throws {\n        let query: [String: Any] = [\n            kSecClass as String: kSecClassGenericPassword,\n            kSecAttrService as String: service,\n            kSecAttrAccount as String: account\n        ]\n        SecItemDelete(query as CFDictionary)\n\n        var attributes = query\n        attributes[kSecValueData as String] = Data(token.utf8)\n        attributes[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly\n\n        let status = SecItemAdd(attributes as CFDictionary, nil)\n        guard status == errSecSuccess else { throw KeychainError(status: status) }\n    }\n}<\/code><\/pre>\n<p>The accessibility class matters. <code>kSecAttrAccessibleWhenUnlockedThisDeviceOnly<\/code> keeps the item encrypted while the device is locked and prevents it from moving to another device through a backup restore. If you need background access (for example, refreshing a token in a background task), use <code>kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly<\/code> and document why.<\/p>\n<h2>Fixing It on Android: Keystore-Backed Encryption<\/h2>\n<p>On Android, the equivalent mistake looks like this:<\/p>\n<pre><code class=\"language-kotlin\">\/\/ Vulnerable: CWE-312. Token written as plaintext XML under shared_prefs\/.\nprefs.edit().putString(\"refresh_token\", refreshToken).apply()<\/code><\/pre>\n<p>The durable fix is to encrypt sensitive values with a key held in the Android Keystore, ideally hardware-backed, before writing them anywhere. Many teams used Jetpack Security&#8217;s <code>EncryptedSharedPreferences<\/code> for this. That library has since been deprecated, so for new code, use a Keystore-backed key directly or a maintained library such as Tink, and store the ciphertext in DataStore or a file.<\/p>\n<p>Also exclude sensitive files from backups. On Android 12 and later, use <code>dataExtractionRules<\/code>:<\/p>\n<pre><code class=\"language-xml\">&lt;!-- res\/xml\/data_extraction_rules.xml --&gt;\n&lt;data-extraction-rules&gt;\n    &lt;cloud-backup&gt;\n        &lt;exclude domain=\"sharedpref\" path=\"auth_prefs.xml\"\/&gt;\n    &lt;\/cloud-backup&gt;\n    &lt;device-transfer&gt;\n        &lt;exclude domain=\"sharedpref\" path=\"auth_prefs.xml\"\/&gt;\n    &lt;\/device-transfer&gt;\n&lt;\/data-extraction-rules&gt;<\/code><\/pre>\n<p>Reference it from the <code>&lt;application&gt;<\/code> element with <code>android:dataExtractionRules<\/code>, and keep <code>android:fullBackupContent<\/code> rules in place for older OS versions.<\/p>\n<h2>Fixing It in React Native and Flutter<\/h2>\n<p>React Native code tends to look like this:<\/p>\n<pre><code class=\"language-typescript\">\/\/ Vulnerable: CWE-922. AsyncStorage is not encrypted.\nimport AsyncStorage from '@react-native-async-storage\/async-storage';\n\nawait AsyncStorage.setItem('refreshToken', refreshToken);<\/code><\/pre>\n<p>The fix is a library that wraps the native Keychain and Keystore, such as <code>react-native-keychain<\/code> (or <code>expo-secure-store<\/code> in Expo apps):<\/p>\n<pre><code class=\"language-typescript\">import * as Keychain from 'react-native-keychain';\n\nconst SERVICE = 'com.example.auth';\n\nexport async function saveRefreshToken(token: string): Promise&lt;void&gt; {\n  await Keychain.setGenericPassword('refreshToken', token, {\n    service: SERVICE,\n    accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,\n  });\n}\n\nexport async function readRefreshToken(): Promise&lt;string | null&gt; {\n  const creds = await Keychain.getGenericPassword({ service: SERVICE });\n  return creds ? creds.password : null;\n}<\/code><\/pre>\n<p>Flutter has the same split. <code>shared_preferences<\/code> is for preferences, and <code>flutter_secure_storage<\/code> is for secrets, since it uses the Keychain on iOS and Keystore-backed encryption on Android.<\/p>\n<h2>Do Not Forget the Migration<\/h2>\n<p>Fixing the code only protects new writes. Every existing user still has the old plaintext value on their device. Ship a one-time migration on app launch: read the old value, write it to secure storage, confirm the write, then delete the original.<\/p>\n<pre><code class=\"language-typescript\">export async function migrateLegacyToken(): Promise&lt;void&gt; {\n  const legacy = await AsyncStorage.getItem('refreshToken');\n  if (!legacy) return;\n  await saveRefreshToken(legacy);\n  await AsyncStorage.removeItem('refreshToken');\n}<\/code><\/pre>\n<p>If the token is long-lived, consider rotating it server-side after migration so that any copies already sitting in backups stop working.<\/p>\n<h2>How to Detect Insecure Storage This Week<\/h2>\n<p>You do not need a new tool to start. Here is a workflow any team can run in an afternoon.<\/p>\n<p><strong>Search the codebase.<\/strong> Grep for <code>UserDefaults<\/code>, <code>SharedPreferences<\/code>, <code>AsyncStorage<\/code>, <code>shared_preferences<\/code>, <code>NSUserDefaults<\/code>, and <code>getSharedPreferences<\/code>. Review every write and ask whether the value is sensitive.<\/p>\n<p><strong>Check your logs.<\/strong> Search for <code>print<\/code>, <code>NSLog<\/code>, <code>Log.d<\/code>, <code>console.log<\/code>, and <code>debugPrint<\/code> near auth code. Tokens in logs are a CWE-532 finding and a common way secrets leak into crash reporting tools.<\/p>\n<p><strong>Inspect a real build.<\/strong> On Android, use <code>adb shell run-as &lt;package&gt;<\/code> on a debuggable build to list <code>shared_prefs\/<\/code>. On iOS, inspect the simulator&#8217;s app container under <code>Library\/Preferences<\/code>. Seeing your own token in plaintext tends to settle debates quickly.<\/p>\n<p><strong>Review backup configuration.<\/strong> Confirm <code>dataExtractionRules<\/code> and <code>fullBackupContent<\/code> on Android, and confirm Keychain accessibility classes on iOS.<\/p>\n<p><strong>Add a CI check.<\/strong> A simple lint rule or Semgrep pattern that flags sensitive-looking key names (<code>token<\/code>, <code>secret<\/code>, <code>password<\/code>, <code>ssn<\/code>) written to plain key-value stores will catch regressions before review.<\/p>\n<p><strong>Write the answer for the auditor now.<\/strong> One paragraph describing where secrets live, how they are encrypted, and how backups are handled. You will be asked again.<\/p>\n<h2>Where This Breaks Down at Scale<\/h2>\n<p>The checklist works. The problem is keeping it working. A team shipping weekly across iOS, Android, and a shared React Native or Flutter layer adds new storage calls constantly. Grep finds candidates, but someone still has to decide which ones matter, write the fix in the right language, and handle the migration. Scanners that flag every <code>SharedPreferences<\/code> call, including harmless ones, train teams to ignore the noise. Real findings then surface days before release, when there is no time to fix them properly.<\/p>\n<h2>How Nandee Automates This<\/h2>\n<p>Nandee is an AI Mobile Application Security Engineer that turns this checklist into one continuous workflow.<\/p>\n<p><strong>Scan.<\/strong> Nandee performs SAST, DAST, and SCA\/OSS analysis across native iOS, native Android, Flutter, and React Native apps. That covers insecure storage in your own code, how the running app behaves, and vulnerable third-party libraries in one pass.<\/p>\n<p><strong>Triage.<\/strong> AI Triage removes false positives and ranks what remains by exploitability. Instead of hundreds of storage warnings, your team sees the findings that actually put tokens, PII, or PHI at risk, so the work goes where it matters.<\/p>\n<p><strong>Fix.<\/strong> The Green Agent writes the code fix in the language the finding lives in: Swift, Kotlin, Dart for Flutter, or JS\/TS for React Native. Your developers review a proposed change instead of starting from a ticket.<\/p>\n<p><strong>Verify.<\/strong> The Red Agent provides AI pentesting to probe your app the way an attacker would. When you need human depth for a launch, an audit, or a high-stakes release, Nandee&#8217;s expert-led manual pentesting covers mobile and web apps. For healthcare teams, Nandee signs BAAs.<\/p>\n<p>Scan, triage, and fix happen in one place. That means insecure storage gets caught and fixed while the code is fresh, not discovered the week your release or your enterprise deal is on the line.<\/p>\n<h2>Stop Answering Question 14 From Scratch<\/h2>\n<p>Insecure data storage is a solved problem on every platform. What is not solved for most teams is catching it on every release without burning a week of engineering time. Nandee makes that continuous, so the next security questionnaire is a quick answer, not a scramble.<\/p>\n<p>Start a scan or book a demo at <a href=\"https:\/\/nandee.ai\/\">nandee.ai<\/a> and see what your current build is storing. Then pick the plan that keeps every future release secure by default.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Tokens in UserDefaults, SharedPreferences, or AsyncStorage are a top mobile finding. How to detect insecure data storage and fix it on every platform.<\/p>\n","protected":false},"author":1,"featured_media":100025,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[5],"tags":[],"class_list":["post-100026","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-owasp-mobile-top-10"],"_links":{"self":[{"href":"https:\/\/www.nandee.ai\/blog\/wp-json\/wp\/v2\/posts\/100026","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.nandee.ai\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.nandee.ai\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.nandee.ai\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.nandee.ai\/blog\/wp-json\/wp\/v2\/comments?post=100026"}],"version-history":[{"count":1,"href":"https:\/\/www.nandee.ai\/blog\/wp-json\/wp\/v2\/posts\/100026\/revisions"}],"predecessor-version":[{"id":100033,"href":"https:\/\/www.nandee.ai\/blog\/wp-json\/wp\/v2\/posts\/100026\/revisions\/100033"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.nandee.ai\/blog\/wp-json\/wp\/v2\/media\/100025"}],"wp:attachment":[{"href":"https:\/\/www.nandee.ai\/blog\/wp-json\/wp\/v2\/media?parent=100026"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.nandee.ai\/blog\/wp-json\/wp\/v2\/categories?post=100026"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.nandee.ai\/blog\/wp-json\/wp\/v2\/tags?post=100026"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}