If you're building an iOS app with a widget, a Watch companion (that's the watchOS app paired with your main iPhone app), or a Share Extension, you'll eventually need to pass data between processes. App Groups are the standard mechanism for this, and on the surface it looks simple: add the capability, write UserDefaults(suiteName:), and off you go. But that apparent simplicity is exactly what causes problems. Data sits in an unencrypted container, any app from your team can read it, and incoming data validation is almost never done. Let's walk through how to set up App Groups properly, what actually belongs there, what risks exist, and how to organize a secure exchange - including a concrete example of passing an authorization token between an app and a widget.
When You Can't Avoid App Groups
iOS sandboxing is strict: every app lives in its own container, and by default there's no access to neighboring processes' files whatsoever. That's great for security, but it creates an obvious problem the moment you have an ecosystem of multiple targets.
Typical scenarios: the main app and a WidgetKit widget need to display the same data; a Share Extension needs to save received content so the main process can handle it on the next launch; a Watch app wants cached data without hitting the network. In all these cases, App Groups are the only native way to set up a shared container without a server in the middle.
It's important to understand that App Groups aren't about real-time inter-process communication. There are no sockets here, no notifications with guaranteed delivery (well, almost - CFNotificationCenter can do some things, but that's a separate story). App Groups are about shared storage. If you need real-time synchronization, look toward XPC or Darwin Notifications built on top of shared storage.
How to Set It Up and Where Everything Breaks
In theory, setting up App Groups takes a minute. In practice, I've seen plenty of projects where it turned into half a day of debugging due to mismatched provisioning profiles.
It starts in Xcode: Signing & Capabilities → + Capability → App Groups. You add an identifier in the format group.com.yourcompany.appname. Important: that same identifier needs to be added to every target participating in the data exchange - the main app, the widget, the extension.
Xcode automatically updates the target's .entitlements file:
<!-- MyApp.entitlements -->
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.yourcompany.myapp</string>
</array>
You'd think that's enough. But no - Xcode also needs to update the provisioning profile for each target through the Apple Developer Portal. If you're managing signing manually, you need to go into Certificates, Identifiers & Profiles, find the App ID for each target, and make sure the App Groups capability is enabled there with the right identifier. Then regenerate the profiles.
One of the most common mistakes: the App Group is registered only for the main App ID but not for the Extension. The behavior here is particularly nasty - everything works fine on the simulator (profile checks are more lenient there), but on a real device the data simply doesn't get read, with no errors in the logs whatsoever.
If you have multiple extension targets with different bundle IDs, make sure all of them are added to the group. This has to be done manually in the portal, and Xcode won't warn you about it.
What You Can Share and What You Shouldn't
The App Group shared container provides a URL like /private/var/mobile/Containers/Shared/AppGroup/<UUID>/. You can write anything there: files, SQLite databases, Core Data stores. UserDefaults with a suite name is just a wrapper around a plist file in that same container.
UserDefaults(suiteName: "group.com.yourcompany.myapp") is the simplest way to share small pieces of data. It works well for flags, simple settings, and last-updated timestamps. I try not to put anything larger than a couple hundred bytes in there - not because there's a hard limit (there isn't), but because UserDefaults loads the entire plist into memory at once.
For larger data, it's better to work directly with files. You get the container URL via:
let containerURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.com.yourcompany.myapp")
Core Data on top of App Groups works, but requires care. The store needs to be created with a URL inside the container, and if multiple processes might access it simultaneously (say, an extension is writing while the main app is reading), you need to use WAL journal mode and accept that NSPersistentContainer doesn't handle cross-process concurrent access automatically. In practice, for inter-process exchange I prefer atomically-written files, keeping Core Data exclusively in the main process.
What you definitely shouldn't do is store large binary data that gets rewritten frequently in the shared container. Each process holds the file open in its own way, and writes can easily produce a race condition. For safe atomic writes, write to a temp file first, then call FileManager.replaceItem(at:withItemAt:).
The Security Risks Nobody Likes to Talk About
The shared container doesn't get any additional encryption beyond iOS's standard filesystem encryption. Data is protected by the NSFileProtectionCompleteUntilFirstUserAuthentication class by default - meaning it's accessible after the first unlock, which in practice means "almost always."
The main risk that's frequently underestimated: any app signed with the same Team ID and registered in the same group has full read and write access. If your team has multiple apps, they can all potentially read each other's data if they're registered in the same group. That's not a bug - it's a feature. That's exactly why naming your group group.com.yourcompany.shared and dumping data from all your apps in there is a terrible idea.
Another attack vector: if an extension gets compromised (via a vulnerability in its content-handling logic), an attacker can write arbitrary data into the shared container, which the main app will then read and process. That's a classic data injection path. So data that the main app reads from the container should be validated just as strictly as data from a server.
On macOS things are slightly different - App Groups work through ~/Library/Group Containers/, and Gatekeeper adds extra checks. But the risks are conceptually identical.
How to Do It Safely
Rule number one: no raw strings in the shared container. I've seen code where authorization tokens were stored as UserDefaults.standard.set(token, forKey: "auth_token") - in a suite accessible to the widget. If it later turns out another app from the same team is registered in that group, the token leaks without leaving any trace.
Use Codable to structure your data. It gives you a schema, versioning, and the ability to validate:
struct SharedAuthState: Codable {
let version: Int
let accessTokenHash: String // hash only, not the token itself
let expiresAt: Date
let isAuthenticated: Bool
}
Notice that the example above stores a hash of the token, not the token itself. The real token should live in the Keychain with kSecAttrAccessGroup, which can also be shared between targets - but that's a much more secure storage with hardware encryption on devices with Secure Enclave.
For data that genuinely needs to be shared in full (say, small cached API responses), it's worth adding encryption on top. A straightforward approach - CryptoKit with a symmetric key from the Keychain:
import CryptoKit
func encrypt(_ data: Data, using key: SymmetricKey) throws -> Data {
let sealedBox = try AES.GCM.seal(data, using: key)
return sealedBox.combined!
}
func decrypt(_ data: Data, using key: SymmetricKey) throws -> Data {
let sealedBox = try AES.GCM.SealedBox(combined: encrypted)
return try AES.GCM.open(sealedBox, using: key)
}
The key is stored in the Keychain with kSecAttrAccessible = kSecAttrAccessibleAfterFirstUnlock and kSecAttrAccessGroup for sharing between targets. The shared container itself then holds only an encrypted blob - useless without the key.
Validation on read is non-negotiable: check the schema version, check that dates aren't in the past, check that fields aren't nil where they shouldn't be. Don't trust data from the container any more than you'd trust data from the network.
Example: Passing Auth State Between the App and a Widget
Here's a concrete implementation. The goal: the widget needs to know whether the user is authenticated, and display either data or a "sign in" call to action.
First, the model and manager for the main app:
// SharedModels.swift (shared target or copied into both targets)
struct WidgetAuthState: Codable {
let version: Int
let isAuthenticated: Bool
let displayName: String?
let tokenExpiresAt: Date?
static let currentVersion = 1
var isValid: Bool {
guard version == Self.currentVersion else { return false }
if isAuthenticated, let expiry = tokenExpiresAt {
return expiry > Date()
}
return true
}
}
// SharedStateManager.swift
final class SharedStateManager {
static let shared = SharedStateManager()
private let groupID = "group.com.yourcompany.myapp"
private let stateFileName = "widget_auth_state.encrypted"
private var containerURL: URL? {
FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: groupID
)
}
private var stateFileURL: URL? {
containerURL?.appendingPathComponent(stateFileName)
}
// Encryption key from Keychain
private func encryptionKey() throws -> SymmetricKey {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.yourcompany.myapp.widgetkey",
kSecAttrAccessGroup as String: "TEAMID.group.com.yourcompany.myapp",
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let keyData = result as? Data {
return SymmetricKey(data: keyData)
}
// Generate a new key on first run
let newKey = SymmetricKey(size: .bits256)
let keyData = newKey.withUnsafeBytes { Data($0) }
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.yourcompany.myapp.widgetkey",
kSecAttrAccessGroup as String: "TEAMID.group.com.yourcompany.myapp",
kSecValueData as String: keyData,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
SecItemAdd(addQuery as CFDictionary, nil)
return newKey
}
func writeAuthState(_ state: WidgetAuthState) throws {
guard let url = stateFileURL else {
throw SharedStateError.containerUnavailable
}
let key = try encryptionKey()
let plaintext = try JSONEncoder().encode(state)
let sealedBox = try AES.GCM.seal(plaintext, using: key)
guard let combined = sealedBox.combined else {
throw SharedStateError.encryptionFailed
}
// Atomic write via temp file
let tempURL = url.deletingLastPathComponent()
.appendingPathComponent(UUID().uuidString)
try combined.write(to: tempURL, options: .atomic)
_ = try FileManager.default.replaceItemAt(url, withItemAt: tempURL)
}
func readAuthState() -> WidgetAuthState? {
guard
let url = stateFileURL,
let encrypted = try? Data(contentsOf: url),
let key = try? encryptionKey(),
let sealedBox = try? AES.GCM.SealedBox(combined: encrypted),
let plaintext = try? AES.GCM.open(sealedBox, using: key),
let state = try? JSONDecoder().decode(WidgetAuthState.self, from: plaintext),
state.isValid
else {
return nil
}
return state
}
}
enum SharedStateError: Error {
case containerUnavailable
case encryptionFailed
}
In the widget, call readAuthState() inside getTimeline or getSnapshot. The main app calls writeAuthState() on login/logout and whenever the token is refreshed.
A few details worth noting. First, the kSecAttrAccessGroup for the Keychain item is different from the App Group identifier - it follows the format TEAMID.identifier. Second, the atomic write through a temp file protects against the widget reading the file mid-write while the main app is in the middle of updating it. Third, isValid on the reader's side isn't an optional nicety - it's mandatory.
Testing and Debugging
The simulator works with App Groups, but there are quirks. The shared container on the simulator lives at ~/Library/Developer/CoreSimulator/Devices/<DeviceID>/data/Containers/Shared/AppGroup/. The easiest way to find it is print(FileManager.default.containerURL(...)) on first run. It's useful to have that folder open in Finder and watch the files in real time - I often keep a Terminal with watch -n1 cat widget_auth_state.encrypted | xxd | head running while debugging.
There are a few ways to reset App Group data on the simulator. The nuclear option is xcrun simctl erase <DeviceID>, which wipes the entire simulator. More surgical: delete the folder with the relevant UUID manually. If you're working with multiple simulators, keep in mind that each one has its own container, even if the app bundle IDs are identical.
A common problem on first launch of an extension: the group container doesn't exist until one of the apps in the group has accessed it. containerURL will return a URL, but no files will have been created there yet. Always check that the directory exists before writing:
if let url = containerURL {
try FileManager.default.createDirectory(
at: url,
withIntermediateDirectories: true
)
}
Debugging on a real device is trickier - you don't have direct filesystem access. Use os_log and Console.app, or temporarily add the file hash directly to your widget's UI. Instruments with the File Activity template lets you see which process is touching the container files and when.
One more thing that's burned me before: when you regenerate a provisioning profile, the container UUID can sometimes change. That means all the data in the old container becomes inaccessible. Graceful degradation when data is missing isn't optional - it's required. The widget must handle nil from readAuthState() gracefully, showing a "not authenticated" state or a placeholder.
Wrapping Up
App Groups are a powerful mechanism that's easy to use insecurely. The key takeaways: use the Keychain for encryption keys and sensitive data, encrypt what you put in the shared container, always validate what you read back, make writes atomic, and build in graceful degradation. Complexity grows non-linearly with the number of targets - the sooner you encapsulate your shared state logic into a dedicated module with clean interfaces, the less pain you'll have down the road.
Top comments (0)