In short

Declare every Android component's android:exported value and export only what must be reachable; verify App Links and Universal Links but still validate their parameters. Keep tokens in the Keychain with a ThisDeviceOnly class or behind Android Keystore keys, exclude sessions and caches from backups, protect cached files, and hide sensitive screens from snapshots. Keep TLS validation on with no cleartext, request permissions only when needed, and leave every authorization decision to the server.

  • Every exported component, link handler and extension is an API that other apps can call with any input.
  • Link verification proves which app opens a URL, not that the user may act on the appointment it names.
  • Protect every copy: logs, backups, notifications, snapshots, clipboard and caches as well as the credential store.
  • Keep platform TLS validation on and test the failure path; certificate pinning adds rotation and recovery obligations of its own.
  • Attestation from Play Integrity or App Attest is a risk signal for the server, never a replacement for its authorization checks.

Verify it

  1. Inspect the merged manifest and confirm only intended components are exported, each with a deliberate android:exported value.
  2. Launch every exported activity and link with extra, missing and foreign parameters, for example with adb shell am start -a android.intent.action.VIEW -d <url>, and confirm no action runs.
  3. Create a backup or device transfer and confirm the session and cached records are absent after restoring to a second device.
  4. Lock the device and open the app switcher; no appointment details may appear in the snapshot or on the lock screen.
  5. Route traffic through a proxy with a user-installed CA and confirm release builds refuse the connection.
Applies to

Android and iOS boundary review. Platform APIs and entitlements need device and OS-specific verification.

Part of
Workflow: Review a design

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 26 minutes: 8 to read and 18 for the exercise

What you will learn

  • Review links and components as external entry points.
  • Trace credential and personal-data copies beyond secure storage.
  • Verify account switching and permission-denied behavior.

Before you start

Android and iOS developers configuring manifests, entitlements and local storage, and reviewers running a MASVS-based assessment.

  • Distinguish an app sandbox, exported component, deep link and platform credential store.

Refresh first: Review trust boundaries in mobile and native clients

Go to the guided practice

Model the operating system as a set of entry points and storage policies

A mobile application communicates with the server, other apps, browser navigation, operating-system services and the user. Each path carries different evidence of identity or intent. An Android exported component can receive requests from another app; an iOS URL handler can receive externally supplied navigation. A verified app link or universal link helps associate a domain with an app, but the link's parameters still require validation and server authorization.

Consider a medical-appointment app that opens appointment A17 for its owner and stores a refresh credential. The feature needs an appointment id, not an arbitrary callback URL, filesystem path or immediate cancellation instruction. A link may open a review screen after authentication; cancellation requires the normal authorized confirmation flow. Platform association is evidence about routing the URL, not permission to act on the selected appointment. Keep domain association files, application entitlements and server permission rules as separate review items.

Trace sensitive data beyond the primary credential store

Use the platform's supported credential storage and choose access restrictions appropriate to the device and workflow. On Apple platforms, Keychain accessibility classes such as kSecAttrAccessibleWhenUnlockedThisDeviceOnly decide when items can be read and whether they migrate to a new device; on Android, Keystore-backed keys can protect cryptographic material, optionally in StrongBox hardware, and encrypt app-specific storage. The exact guarantee depends on configuration, hardware and operating-system version. Avoid asserting that any item labeled secure storage is unreadable under every device-compromise scenario.

Follow copies: notification text, recent-app snapshots, logs, crash reports, clipboard, backups, temporary files and cached API responses. If the refresh credential is carefully protected but copied into a debug log or URL, the primary storage choice did not protect that copy. Define account-switch and logout behavior for both credentials and cached appointments. Revoke refresh authority server-side where the session design supports it; deleting a local file alone does not invalidate a copied credential. Minimize offline data and set an explicit retention rule.

The Swift code below stores the refresh token with kSecAttrAccessibleWhenUnlockedThisDeviceOnly, deletes it at logout and account switch, and writes cached appointments with complete file protection, excluded from backups. It compiles against the iOS 27 SDK. The Simulator doesn't enforce Data Protection, so check file protection on a device. Keychain calls work in the Simulator from tests hosted in an app, which Xcode signs to run locally; a test bundle without a host app gets errSecMissingEntitlement (-34018). The iOS project on /labs/ runs the Keychain checks that way.

FixedTokenStore.swiftSwift
// TokenStore.swift (iOS 15+): local copies of the session, each with its own protection.
import Foundation
import Security

enum TokenStore {
    static let service = "com.example.appointments.refresh"

    // Refresh token in the Keychain: readable only while unlocked, never restored to another device.
    static func save(_ token: Data, account: String) throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
        ]
        SecItemDelete(query as CFDictionary)
        var item = query
        item[kSecValueData as String] = token
        item[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
        let status = SecItemAdd(item as CFDictionary, nil)
        guard status == errSecSuccess else { throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) }
    }

    static func load(account: String) -> Data? {
        var result: AnyObject?
        let status = SecItemCopyMatching([
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecReturnData as String: true,
        ] as CFDictionary, &result)
        return status == errSecSuccess ? result as? Data : nil
    }

    // Logout and account switch: delete the local copy; the server revokes the token itself.
    static func delete(account: String) {
        SecItemDelete([
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
        ] as CFDictionary)
    }
}

// Cached appointments: unreadable while the device is locked, and excluded from backups.
func writeAppointmentCache(_ data: Data, to url: URL) throws {
    try data.write(to: url, options: [.atomic, .completeFileProtection])
    var values = URLResourceValues()
    values.isExcludedFromBackup = true
    var file = url
    try file.setResourceValues(values)
}

Narrow links, permissions and inter-app requests

A link handler should parse a fixed operation and id format, reject extra action parameters and route through the same authenticated API used by the normal screen. Do not implement a generic open-any-URL or execute-any-action bridge. For Android components and content providers, review exported status, required permissions, URI grants and whether callers can select arbitrary resources; apps targeting Android 12 or later must declare android:exported on every component with an intent filter, and anything another app does not need should say false. For iOS extensions and URL handlers, review the equivalent data-sharing and invocation boundaries rather than assuming the main app's UI guards apply.

Request only platform permissions the feature needs, at the point the user understands why. Denial and later revocation must produce a safe degraded mode, not a crash or broader fallback. A contacts permission is not needed to view a server-owned appointment. Treat a WebView as a browser with native capabilities attached: one that displays outside content should not inherit a bridge capable of reading credentials or making privileged native calls. Native bridges require typed methods, source validation and operation-specific checks. Keep TLS certificate validation enabled and test failure paths; certificate pinning adds operational rotation and recovery obligations and is not a universal replacement for standard trust validation.

The manifest excerpt below exports one activity, reachable only through a verified App Link to /view, keeps the FileProvider unexported so attachments leave only through temporary URI grants, and points to backup and network rules that exclude the session and cached records and refuse cleartext and user-installed certificate authorities. The manifest and its four XML resources linked without errors with aapt2 9.4.1 against the Android 37.1 platform, which rejects misspelled attributes such as android:exportd.

ConfigurationAndroidManifest.xmlXML
<!-- AndroidManifest.xml (excerpt): one exported entry point, nothing else reachable. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:networkSecurityConfig="@xml/network_security_config">
        <!-- Verified App Links open the view screen; the activity still validates the id. -->
        <activity android:name=".AppointmentActivity" android:exported="true">
            <intent-filter android:autoVerify="true">
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <data android:scheme="https" android:host="appointments.example.test"
                    android:path="/view" />
            </intent-filter>
        </activity>
        <!-- Shares one chosen attachment through a temporary URI grant, never a directory. -->
        <provider
            android:name="androidx.core.content.FileProvider"
            android:authorities="com.example.appointments.files"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/file_paths" />
        </provider>
    </application>
</manifest>

Map each control to its Android and iOS setting

The same requirement uses different mechanisms on each platform. The table pairs them and names the MASVS v2.1.0 control a reviewer checks; MASVS-STORAGE-2 is "the app prevents leakage of sensitive data" and MASVS-PLATFORM-1 is "the app uses IPC mechanisms securely". MASTG 2.0 tests cite MASWE weaknesses, which MASWE 1.0.0 renumbered in August 2026, so check an ID on mas.owasp.org before you cite it. Platform-specific detail is in /domains/appsec/platform-security/android-secure-coding/ and /domains/appsec/platform-security/ios-secure-coding/.

Map each control to its Android and iOS setting
ControlAndroidiOSMASVS
Entry pointsExplicit android:exported; permissions on exported componentsOnly the URL schemes, Universal Links and extensions you needMASVS-PLATFORM-1
Verified linksandroid:autoVerify and assetlinks.jsonAssociated Domains applinks: and apple-app-site-associationMASVS-PLATFORM-1
Sharing a fileFileProvider with FLAG_GRANT_READ_URI_PERMISSION for one URIShare sheet with the single file; no shared container pathMASVS-PLATFORM-1
CredentialsAndroid Keystore key protecting the tokenKeychain with a ThisDeviceOnly classMASVS-STORAGE-1
BackupsdataExtractionRules and fullBackupContent exclusionsThisDeviceOnly Keychain items; isExcludedFromBackup filesMASVS-STORAGE-2
Screens and snapshotsFLAG_SECURE on sensitive windowsCover sensitive content in sceneWillResignActiveMASVS-PLATFORM-3
TransportNetwork security config: no cleartext, system CAsApp Transport Security defaultsMASVS-NETWORK-1
Device integrity signalPlay Integrity APIApp Attest (DCAppAttestService)MASVS-RESILIENCE-1

Verify on the supported device and account lifecycle

Inspect the application's behavior when opened through a link while signed out, signed in as another user, after permission revocation and after switching accounts. Check both network responses and local display. A denied server request can coexist with a stale appointment card from a previous account. Backup restoration and reinstall may preserve some data depending on platform settings; verify the actual policy for the supported OS versions instead of assuming uninstall is a universal erasure event.

Record synthetic identifiers and state transitions during testing without capturing real patient data or credentials. A platform security checklist provides coverage categories, while a feature-specific trace supplies the expected outcome. Treat attestation or device-integrity failures as risk signals with an explicit response. They may support high-risk transaction policy, but cannot replace server ownership checks or make an externally supplied link trustworthy.

Two local projects on /labs/ run these boundaries as tests against a vulnerable and a fixed version of one banking app. The iOS one runs an XCTest suite in the Simulator: Keychain storage, the built Info.plist's ATS settings, link parsing and a WKWebView bridge. The Android one runs Robolectric tests and Android Lint in a pinned container: links and intent redirection, exported components, network security configuration and token storage. Neither replaces the device checks above.

A real-world failure

CVE-2021-30860 (FORCEDENTRY), a zero-click CoreGraphics integer overflow exploited by Pegasus spyware on iOS, shows that platform hardening does not remove the need to patch the libraries that parse untrusted input and to minimize how much code touches it.

Validate platform-specific assumptions with targeted evidence

The same product requirement can need different platform controls. An Android content provider sharing an attachment may grant temporary URI access to a chosen recipient; an iOS share extension follows a different container and entitlement model. Do not translate one platform's checklist item by name and assume the authority is equivalent. Record who can invoke the component, what data they can select, how long access lasts and which process verifies the selection.

For an attachment to A17, the useful property is that another app cannot request B22's file by changing a URI or message field. The server may already deny B22, but a local provider could still expose a cached copy. Validate ownership when issuing the share capability and scope it to the selected file. Avoid granting an entire directory when the user chose one attachment, and make temporary permissions expire according to the intended workflow.

Notification and recent-app previews are another separate presentation boundary. A locked device displaying a sensitive appointment description may violate the product's privacy expectation even when storage encryption works correctly. Decide which fields can appear in those surfaces and test the locked/unlocked transition with synthetic records. Platform documentation and MASVS organize the review, but the resulting verification must name the actual observable data and device state. This keeps platform security work tied to user-facing confidentiality instead of accumulating settings whose effects nobody has measured.

Guided practice

Review appointment links and local data with a complete policy.

Supplied scenario material
Plain text
Server: appointment A17 belongs to Alice; B22 belongs to Bob. Alice session T1 is valid.
Link contract: https://appointments.example.test/view?id=<A or B followed by digits>; view only; no extra fields. A trusted platform association routes this origin to the app.
L1=https://appointments.example.test/view?id=A17
L2=https://appointments.example.test/view?id=B22
L3=https://appointments.example.test/view?id=A17&action=cancel
L4=https://appointments.example.test.attacker.invalid/view?id=A17
Local copies: secure credential store=T1; debug log contains T1; cache contains A17 at 09:00.
Logout policy: revoke T1 refresh authority and clear appointment cache. Next user Bob signs in. Contacts permission denied; appointment view does not require it.
  1. 1. Separate routing from authorization

    Evaluate L1 and L2 as Alice even though both use the associated origin.

    Expected result and reasoning

    L1 opens the authorized A17 view. L2 may route to the app but the server denies B22 to Alice. Domain association does not authorize the selected appointment.

  2. 2. Reject expanded link capabilities

    Evaluate L3 and L4 under the exact contract.

    Expected result and reasoning

    L3 rejects the extra action field and cannot cancel A17. L4 has the wrong host and is not a trusted application link. Prefix matching would misclassify it.

  3. 3. Find secondary credential exposure

    Identify the control gap in the local-copy inventory and define the fix.

    Expected result and reasoning

    T1 in the debug log remains a credential leak despite secure storage. Remove/redact that logging and follow the session response for any exposed live token; protect every copy, not just the designated store.

  4. 4. Verify logout and permission denial

    State what Bob sees after account switch and whether denied contacts access should prevent viewing Bob's own B22.

    Expected result and reasoning

    Bob must not see Alice's cached A17; T1 cannot refresh online after revocation. Contacts denial should not block B22 because the feature does not need contacts. No broader permission fallback is justified.

Your deliverable: A link decision table and a storage/account-switch checklist with exact expected visible and server state.

Paper exercise: links, local copies and policies are supplied. The manifest and Swift code above show the platform settings for the same review, and the iOS and Android projects on /labs/ test them.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Run a local project

Download and unzip a starter with failing tests and a step-by-step README, edit the named exercise file, and run the tests until they pass. The reference solution is a separate download, so you can attempt the repair before you see it.

Swift 6 · Xcode 16+ · iOS Simulator · 45–75 min

Keep an iOS app's storage, links and web bridge in bounds

Repair a SwiftUI banking app until its XCTest suite passes in the iOS Simulator: Keychain storage, App Transport Security in the built Info.plist, link handling and a WKWebView bridge.

Needs: A Mac with Xcode 16 or later and an iOS Simulator; no signing or developer account.

In the extracted folder, run:

Shell
sh run-tests.sh

19 regression tests · The starter intentionally fails some cases.

No dev container: the iOS SDK runs only on macOS.

Real Keychain, ATS and WebKit behavior in the Simulator. Data Protection and universal-link association need a device or a signed build. Verified with Xcode 27.0 on iOS 27.0 and 26.5.

Kotlin · Android Gradle Plugin 9.4 · Robolectric 4.17 · Docker · 45–75 min

Repair an Android app's links, components, network policy and storage

Repair a Kotlin banking app until its Robolectric tests and Android Lint pass in a pinned SDK container: links and intent redirection, exported screens, network security configuration and Keystore-encrypted storage.

Needs: Docker, running the image as linux/amd64 (Rosetta or emulation on Apple silicon), and about 1.2 GB of downloads on the first run.

In the extracted folder, run:

Shell
docker run --rm --init --platform linux/amd64 \
  -v "$PWD":/project -w /project \
  -v android-boundaries-gradle:/root/.gradle -v android-boundaries-m2:/root/.m2 \
  -e EXERCISE_MODULE \
  ghcr.io/cirruslabs/android-sdk:36@sha256:f9b3ea9ed2b5fc9522adae82c7b4622ab7aa54207ef532c8e615a347dca08f31 \
  sh run-tests.sh

20 regression tests · The starter intentionally fails some cases.

No dev container: the README's docker command runs the pinned SDK image.

JVM tests on Robolectric's Android 16 framework plus Lint, not an emulator: the Keystore and network configuration are checked as key specification and compiled resource.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. OWASP MASVS and MASTG
  2. Android security best practices
  3. Apple Platform Security: keychain data protection
  4. Android: verify App Links
  5. Android: back up user data with Auto Backup
  6. Android: network security configuration
  7. Apple: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
  8. Apple: supporting associated domains
  9. Apple: establishing your app's integrity (App Attest)
  10. Android: Play Integrity API
  11. OWASP Mobile Top 10 2024
  12. NVD: CVE-2021-30860, iOS CoreGraphics zero-click (FORCEDENTRY)
  13. OWASP MASWE v1.0.0 release (renumbered weaknesses). v1.0.0.

Reference information

Download this guide as Markdown