DEV Community

Konstantin Shkurko
Konstantin Shkurko

Posted on

Secure Data Sharing Between iOS Apps

Inter-process communication in iOS is a tricky business. Apple has built an entire system of sandboxes and restrictions, and you can't just transfer data from one app to another willy-nilly. But once you figure it out, a world of possibilities opens up: from basic image sharing to building entire app ecosystems. Let's break down all the main methods of data exchange between apps (from URL Schemes to App Groups) with a focus on security and real problems you might encounter. I'll show you code, explain where each method fits, and teach you how to avoid creating holes in user data protection.

URL Schemes: Simplicity with a Catch

URL Schemes are the most obvious way to launch one app from another and pass it some data. The mechanics are simple: you register your scheme in Info.plist, like myapp://, and any app can call yours at this address.

// Opening another app
if let url = URL(string: "myapp://action?param=value") {
    UIApplication.shared.open(url)
}
Enter fullscreen mode Exit fullscreen mode

In the receiving app, you catch this in AppDelegate or SceneDelegate:

func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
    guard let url = URLContexts.first?.url else { return }

    if url.scheme == "myapp" {
        handleDeepLink(url)
    }
}
Enter fullscreen mode Exit fullscreen mode

The problem is that URL Schemes are a public interface. Any app can call your scheme, and you won't know who exactly. Early in my development career, I once made a payment function via URL Scheme - passing the amount and product ID right in the parameters. Naturally, my lead pointed out in code review that an attacker could substitute the parameters and conduct a transaction for pennies. Had to redo it: now I only pass a token and verify the amount on the backend.

Never trust data from URL Schemes. Validate everything that comes in, and don't pass sensitive information in plain text.

Universal Links: The Civilized Approach

Universal Links appeared in iOS 9, and it's a completely different level. Instead of a custom scheme, you use a regular HTTPS domain. When a user taps a link, the system first checks if there's an app associated with this domain, and if there is - opens the app instead of Safari.

Setup is a bit more complex. You need an apple-app-site-association file on your server:

{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "TEAMID.com.example.app",
        "paths": ["/action/*", "/promo/*"]
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

This file must be at the domain root or in .well-known/apple-app-site-association and served over HTTPS without redirects. Apple verifies it when the app is installed.

In Xcode, you add the Associated Domains capability:

applinks:example.com
Enter fullscreen mode Exit fullscreen mode

And handle it the same way as URL Schemes, but through a different method:

func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
    guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
          let url = userActivity.webpageURL else { return }

    handleUniversalLink(url)
}
Enter fullscreen mode Exit fullscreen mode

Universal Links solve the security problem at the infrastructure level. Only the domain owner can configure the association, so faking such a link is much harder. Plus they work even if the app isn't installed - the user simply lands on the website.

Downsides: setup requires server control, and if something goes wrong with the certificate or association file, you'll be debugging for a long time. Apple caches these files, and updates can take hours.

Custom Document Types: File Exchange

Sometimes you need not just to pass parameters, but to share a file. This is where UTI (Uniform Type Identifiers) and Document Types come into play.

You need to register in Info.plist the document types your app can open:

<key>CFBundleDocumentTypes</key>
<array>
    <dict>
        <key>CFBundleTypeName</key>
        <string>My Document</string>
        <key>LSItemContentTypes</key>
        <array>
            <string>com.example.mydoc</string>
        </array>
        <key>LSHandlerRank</key>
        <string>Owner</string>
    </dict>
</array>
Enter fullscreen mode Exit fullscreen mode

And export your UTI:

<key>UTExportedTypeDeclarations</key>
<array>
    <dict>
        <key>UTTypeIdentifier</key>
        <string>com.example.mydoc</string>
        <key>UTTypeConformsTo</key>
        <array>
            <string>public.data</string>
        </array>
        <key>UTTypeTagSpecification</key>
        <dict>
            <key>public.filename-extension</key>
            <array>
                <string>mydoc</string>
            </array>
        </dict>
    </dict>
</array>
Enter fullscreen mode Exit fullscreen mode

Now, when another app sends a file through Share Sheet or Files, your app will appear in the list.

Handling happens through the same method as URL Schemes:

func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
    guard let url = URLContexts.first?.url else { return }

    // This is a security-scoped URL, need to work with it carefully
    guard url.startAccessingSecurityScopedResource() else { return }
    defer { url.stopAccessingSecurityScopedResource() }

    // Copy the file to our own storage
    let data = try? Data(contentsOf: url)
    // Process...
}
Enter fullscreen mode Exit fullscreen mode

Important point: the file you received is in someone else's sandbox. The system grants temporary access through a security-scoped bookmark. If you need to save the file long-term, copy it to your app's Documents or Cache. And always verify the contents - who knows what's actually in there.

App Groups: Shared Data for Your Own Apps

When you have multiple apps or extensions that need to share data directly, App Groups is what you need. Enable the capability in Xcode, create a group with an identifier like group.com.example.shared, and you get a shared container for files and UserDefaults.

// Writing to shared UserDefaults
if let sharedDefaults = UserDefaults(suiteName: "group.com.example.shared") {
    sharedDefaults.set("some value", forKey: "sharedKey")
}

// Path to shared directory
if let containerURL = FileManager.default.containerURL(
    forSecurityApplicationGroupIdentifier: "group.com.example.shared"
) {
    let filePath = containerURL.appendingPathComponent("data.json")
    try? someData.write(to: filePath)
}
Enter fullscreen mode Exit fullscreen mode

This is convenient for widgets, keyboard extensions, Share Extensions. For example, I had an app with a widget. The main app would download data and put it in the App Group, and the widget would simply read from there.

But there's a nuance: App Groups don't provide inter-process synchronization. If both apps write to the same file simultaneously, you'll get a data race. You need to either use locks via NSFileCoordinator or organize exchange through notifications.

// Notifying another app about changes
extension Notification.Name {
    static let dataUpdated = Notification.Name("com.example.dataUpdated")
}

// In the source app
NotificationCenter.default.post(name: .dataUpdated, object: nil)

// In the receiving app (or extension)
CFNotificationCenterAddObserver(
    CFNotificationCenterGetDarwinNotifyCenter(),
    Unmanaged.passRetained(self).toOpaque(),
    { _, observer, name, _, _ in
        // Handle changes
    },
    "com.example.dataUpdated" as CFString,
    nil,
    .deliverImmediately
)
Enter fullscreen mode Exit fullscreen mode

Darwin Notifications work between processes but don't carry a payload. It's just a signal that it's time to re-read the data.

Keychain Sharing: For Real Secrets

If you need to share tokens, passwords, or other sensitive data between apps from the same developer, there's Keychain Sharing. Works similarly to App Groups, but for Keychain.

You need to enable the Keychain Sharing capability and specify a group (usually matches the Team ID):

let query: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrAccount as String: "userToken",
    kSecAttrAccessGroup as String: "TEAMID.com.example.shared",
    kSecValueData as String: tokenData,
    kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]

SecItemAdd(query as CFDictionary, nil)
Enter fullscreen mode Exit fullscreen mode

Keychain is automatically encrypted by the system, and data is protected by hardware encryption on devices with Secure Enclave. This is the only proper way to store secrets in iOS.

I always use a wrapper like KeychainAccess or write my own, because working with the Security framework directly is quite an experience. But the point is that through kSecAttrAccessGroup your apps see the same records.

Share Extension: System Integration

Share Extension allows your app to appear in the system Share Sheet. This is a powerful mechanism for receiving data from other apps - text, images, links, anything.

You create a new target of type Share Extension in Xcode and get a standard ShareViewController:

class ShareViewController: SLComposeServiceViewController {
    override func isContentValid() -> Bool {
        // Content validation
        return true
    }

    override func didSelectPost() {
        guard let item = extensionContext?.inputItems.first as? NSExtensionItem,
              let attachments = item.attachments else { return }

        for attachment in attachments {
            if attachment.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
                attachment.loadItem(forTypeIdentifier: UTType.url.identifier) { url, error in
                    if let url = url as? URL {
                        // Save to App Group for main app
                        self.saveSharedURL(url)
                    }
                }
            }
        }

        extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
    }
}
Enter fullscreen mode Exit fullscreen mode

Extensions run in a separate process with strict memory limits (usually 120 MB). If you exceed it, the system will kill the process without warning. So heavy processing is better postponed and done in the main app.

Data is passed through App Groups, and the main app can be woken up via URL Scheme or background fetch (I prefer this method). I usually save received data to the shared container, write a flag to UserDefaults, and on next launch the main app picks them up for processing.

XPC Services: For Advanced Users

XPC (inter-process communication via mach ports) is a low-level mechanism for communication between processes in macOS and iOS. In iOS it's only available for your own apps and extensions, but gives very precise control.

You create a protocol for communication:

@objc protocol DataServiceProtocol {
    func fetchData(completion: @escaping ([String]) -> Void)
}
Enter fullscreen mode Exit fullscreen mode

And implement it in the XPC Service:

class DataService: NSObject, DataServiceProtocol {
    func fetchData(completion: @escaping ([String]) -> Void) {
        // Some heavy work
        completion(["data1", "data2"])
    }
}
Enter fullscreen mode Exit fullscreen mode

This is useful when you need to isolate dangerous or resource-intensive code. For example, parsing untrusted data can be moved to a separate process. If it crashes, the main app will continue working.

Truth is, in everyday iOS app development, XPC is almost never used. It's more for system-level tasks or macOS. But it's useful to know about.

What to Pay Attention To

Data protection during exchange isn't just about choosing the right API. Here are some things I've learned from practice:

Input data validation. Whatever comes from another app - verify the format, size, content. Especially if it's files. You never know if they'll slip you a 100 MB image or a disguised executable.

Data Protection classes. When saving files to a shared container, don't forget about protection attributes:

try data.write(to: fileURL, options: .completeFileProtection)
Enter fullscreen mode Exit fullscreen mode

completeFileProtection means the file is encrypted and inaccessible while the device is locked. For most cases, this is the right choice.

Minimizing permissions. If an extension doesn't need photo access - don't request it. If you can do without background modes - do without. Apple is very picky about permissions in App Review.

Cleaning up temporary data. Share Extensions and other extensions should clean up after themselves. Don't forget to delete temporary files after processing:

defer {
    try? FileManager.default.removeItem(at: tempURL)
}
Enter fullscreen mode Exit fullscreen mode

What to Use and When

Usually the choice of method is dictated by the task:

  • Just need to open another app with parameters? URL Scheme is enough, just don't pass anything critical.
  • Want civilized integration with the ability to fallback to web? Universal Links.
  • Working with files that users can open from different apps? Document Types.
  • Your own apps need to share data? App Groups for files and UserDefaults, Keychain Sharing for secrets.
  • Want users to be able to share content to your app from anywhere? Share Extension.

I usually start with the simplest solution and only complicate if needed. Universal Links are cooler than URL Schemes, but require more setup. App Groups are convenient, but add dependencies between apps. All this needs to be considered. Each method has its niche, and it's important to understand not only how to use them, but what threats they carry. The main thing - always think about who and how can use your data exchange interface. Validate, encrypt, restrict permissions. User data protection isn't a checkbox on a checklist, it's the foundation of user trust in your app.

Top comments (0)