# Keychain Sharing Across Apps and Extensions

Access groups are the one mechanism for letting several apps and extensions read the
same Keychain items. Getting them right takes three things together: the exact Team ID
prefix, an entitlement on every target that participates, and an explicit
`kSecAttrAccessGroup` in the code.

Behaviour described here holds through iOS 18 and macOS Sequoia 15. It is based on
Apple's "Sharing Access to Keychain Items Among a Collection of Apps", TN3137 (On Mac
keychain APIs and implementations), the iCloud Keychain chapter of the Platform
Security Guide, "Configuring Keychain Sharing", and guidance from Apple DTS engineers
on the developer forums (updated May 2025).

## Contents

- [How access groups work](#how-access-groups-work)
- [Two entitlements with two formats](#two-entitlements-with-two-formats)
- [Code](#code)
- [Setting up every target](#setting-up-every-target)
- [macOS has two keychains](#macos-has-two-keychains)
- [Moving an item to another group](#moving-an-item-to-another-group)
- [Lifecycle surprises](#lifecycle-surprises)
- [Platform notes](#platform-notes)
- [Signing and distribution](#signing-and-distribution)
- [When sharing breaks](#when-sharing-breaks)
- [Threat model](#threat-model)
- [2024 to 2026](#2024-to-2026)
- [Related](#related)
- [Checklist](#checklist)

## How access groups work

An app can belong to many access groups. Every item belongs to exactly one.
`securityd` compares the calling process's entitlements with the item's group on every
call.

The system builds the app's list of groups in this order:

1. Entries of the `keychain-access-groups` entitlement.
2. The application identifier, `TeamID.BundleID`, added automatically.
3. Entries of `com.apple.security.application-groups` (iOS 8 and later).

The first entry in that list is the default: `SecItemAdd` without
`kSecAttrAccessGroup` puts the item there. `SecItemCopyMatching` without a group
searches all of the app's groups.

Because the application identifier always comes before app groups, a keychain access
group can be the default but an app group never can.

Sharing is limited to one development team; the signed provisioning profile enforces
the Team ID prefix. Between different teams the only route is iCloud Keychain
together with Associated Domains, which serves password autofill rather than shared
app data.

## Two entitlements with two formats

Mixing up Keychain Sharing and App Groups is the most common mistake.

| | Keychain Sharing | App Groups |
| --- | --- | --- |
| Entitlement | `keychain-access-groups` | `com.apple.security.application-groups` |
| Value in entitlements | `$(AppIdentifierPrefix)com.example.vault` | `group.com.example.suite` |
| Resolved value | `A1B2C3D4E5.com.example.vault` | `group.com.example.suite` (no Team ID) |
| Shares | Keychain items | Containers, `UserDefaults(suiteName:)`, IPC, and on iOS also Keychain items |

`$(AppIdentifierPrefix)` is replaced with `TEAMID.` at signing time. Code never sees
that substitution, so it must use the full resolved string.

Since iOS 8 an app group name can be used as a `kSecAttrAccessGroup` value, though
never as the default. On macOS app groups cannot act as keychain access groups; that
is iOS and iPadOS only. On macOS, Keychain Sharing groups work only in the data
protection keychain.

Both entitlements can coexist. If you only need shared credentials, add Keychain
Sharing. If the apps already share an App Group, it can carry Keychain items on iOS as
long as every call names it explicitly.

## Code

Writing to a shared group:

```swift
import Security

enum SharedVault {
    static let group = "A1B2C3D4E5.com.example.vault"
    static let service = "com.example.vault.session"
}

func publishSessionToken(_ token: Data) -> OSStatus {
    let identity: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccessGroup as String: SharedVault.group,
        kSecAttrService as String: SharedVault.service,
        kSecAttrAccount as String: "active-user"
    ]
    var insert = identity
    insert[kSecValueData as String] = token
    insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly

    let added = SecItemAdd(insert as CFDictionary, nil)
    guard added == errSecDuplicateItem else { return added }   // -34018: entitlement missing
    let changes: [String: Any] = [kSecValueData as String: token]
    return SecItemUpdate(identity as CFDictionary, changes as CFDictionary)
}
```

The Team ID in code is the literal ten-character string. `$(AppIdentifierPrefix)`
works only inside the entitlements plist.

```swift
// Broken: group without the Team ID prefix
let wrongGroup = "com.example.vault"
```

This fails with `errSecMissingEntitlement` (-34018) on iOS 13 and later, and with
`errSecItemNotFound` (-25300) on earlier versions. Xcode's Keychain Sharing editor
shows the name without its prefix, which is why this keeps happening.

Reading it from an extension:

```swift
func readSessionTokenInExtension() -> Data? {
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccessGroup as String: SharedVault.group,
        kSecAttrService as String: SharedVault.service,
        kSecAttrAccount as String: "active-user",
        kSecReturnData as String: true,
        kSecMatchLimit as String: kSecMatchLimitOne
    ]
    var found: CFTypeRef?
    guard SecItemCopyMatching(query as CFDictionary, &found) == errSecSuccess else { return nil }
    return found as? Data
}
```

An extension that lacks its own Keychain Sharing capability gets -34018 here even
when the host app has the capability. Every executable target (app, widget, share
extension, notification service extension) needs the entitlement itself. Frameworks
carry no entitlements; only the targets that link them do.

### iCloud sync

Syncing is a separate, per-item opt-in:

```swift
func publishSyncedPreferenceKey(_ secret: Data) -> OSStatus {
    let identity: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccessGroup as String: SharedVault.group,
        kSecAttrService as String: "com.example.vault.sync",
        kSecAttrAccount as String: "reader-settings-key",
        kSecAttrSynchronizable as String: kCFBooleanTrue as Any
    ]
    var insert = identity
    insert[kSecValueData as String] = secret
    insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock

    let added = SecItemAdd(insert as CFDictionary, nil)
    guard added == errSecDuplicateItem else { return added }
    return SecItemUpdate(identity as CFDictionary,
                         [kSecValueData as String: secret] as CFDictionary)
}
```

Rules for synced items:

- Apple documents that a synchronizable item may not use an accessibility value
  ending in `ThisDeviceOnly`. `SecItemAdd` rejects the combination with `errSecParam`
  (-50); nothing is stored and nothing syncs. Use `AfterFirstUnlock` or `WhenUnlocked`.
- Every query, update and delete that should see synced items must include
  `kSecAttrSynchronizable: true` or `kSecAttrSynchronizableAny`; without it, synced
  items are excluded from the match.
- Sync needs iCloud Keychain turned on and the same Apple Account on each device. It
  is independent of which access group the item is in.
- `kSecAttrSynchronizable` defaults to false. Nothing syncs unless you ask for it item
  by item. Synced items are end-to-end encrypted; Apple cannot read them.

## Setting up every target

Extensions do not inherit capabilities from the app that contains them.

1. Select the app target, open Signing & Capabilities, click + Capability, add
   Keychain Sharing and enter the group. Xcode applies the Team ID prefix.
2. Do exactly the same for each extension target.
3. If App Groups are used too, add the same `group.` identifier to each target.

| Target | keychain-access-groups | application-groups |
| --- | --- | --- |
| Main app | `A1B2C3D4E5.com.example.vault` (first entry, so the default) | `group.com.example.suite` |
| Share extension | `A1B2C3D4E5.com.example.vault` | `group.com.example.suite` |
| Widget extension | `A1B2C3D4E5.com.example.vault` | `group.com.example.suite` |
| Notification extension | `A1B2C3D4E5.com.example.vault` | `group.com.example.suite` |

Each target is signed with its own provisioning profile.

## macOS has two keychains

- **File-based keychain.** The legacy implementation: `.keychain-db` files and
  `SecAccess` ACLs. On macOS it is where `SecItem` calls go by default. It has no
  iCloud Keychain, no biometrics, no Secure Enclave keys and no access groups.
- **Data protection keychain.** The iOS implementation, on macOS since 10.9 through
  iCloud Keychain. It has access groups, `SecAccessControl`, sync, Touch ID and
  Secure Enclave keys. It is available only in a user login context, so `launchd`
  daemons cannot use it.

On macOS `kSecAttrAccessGroup` is ignored without an error unless the call targets the
data protection keychain:

```swift
let macQuery: [String: Any] = [
    kSecClass as String: kSecClassGenericPassword,
    kSecAttrService as String: SharedVault.service,
    kSecAttrAccessGroup as String: SharedVault.group,
    kSecUseDataProtectionKeychain as String: true
]
```

Either `kSecUseDataProtectionKeychain: true` or `kSecAttrSynchronizable: true` routes
a call there. Mac Catalyst apps and iOS apps running on a Mac always use it.

| Platform | Default keychain | Access groups by default | Flag needed |
| --- | --- | --- | --- |
| iOS, iPadOS, Mac Catalyst | Data protection | Yes | No |
| macOS (AppKit) | File-based | No | `kSecUseDataProtectionKeychain` |

TN3137 describes the file-based keychain as on the road to deprecation;
`SecKeychainCreate` is deprecated as of the macOS 12 SDK. Only `launchd` daemons with no
user context should keep using it.

## Moving an item to another group

`SecItemUpdate` cannot change `kSecAttrAccessGroup`. To move an item, read it with
`SecItemCopyMatching`, add a copy in the new group, and delete the original only after
that add returned `errSecSuccess`. If the add fails, the original is untouched.

```swift
func moveItem(account: String, from oldGroup: String, to newGroup: String) -> Bool {
    let source: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrService as String: SharedVault.service,
        kSecAttrAccount as String: account,
        kSecAttrAccessGroup as String: oldGroup
    ]
    var lookup = source
    lookup[kSecReturnData as String] = true
    var value: CFTypeRef?
    guard SecItemCopyMatching(lookup as CFDictionary, &value) == errSecSuccess,
          let secret = value as? Data else { return false }

    var target = source
    target[kSecAttrAccessGroup as String] = newGroup
    target[kSecValueData as String] = secret
    target[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
    let added = SecItemAdd(target as CFDictionary, nil)
    guard added == errSecSuccess || added == errSecDuplicateItem else { return false }

    return SecItemDelete(source as CFDictionary) == errSecSuccess
}
```

## Lifecycle surprises

- Keychain items survive uninstalling the app. This is long-standing but
  undocumented; an iOS 10.3 beta deleted them on removal and the change was reverted.
  Apple DTS has warned it could change without notice.
- Deleting app A leaves group items in place for app B. Deleting every app in the group
  still leaves the items as orphans; only erasing the device reliably removes them.
- A fresh install therefore can find old items. UserDefaults is removed on uninstall,
  so a flag there tells a first launch apart:

```swift
func purgeLeftoversOnFreshInstall() {
    let marker = "vault.didLaunch"
    guard !UserDefaults.standard.bool(forKey: marker) else { return }
    let scoped: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrService as String: SharedVault.service,
        kSecAttrAccessGroup as String: SharedVault.group
    ]
    SecItemDelete(scoped as CFDictionary)
    UserDefaults.standard.set(true, forKey: marker)
}
```

  Scope the delete by service and group; never wipe everything in a shared group,
  because sibling apps may still be installed and using it. The complete launch
  sequence is in [migration-legacy-stores.md](migration-legacy-stores.md).
- Transferring an app to another developer team orphans its items under the old Team
  ID. The only workaround is to transfer back, ship an update that exports or migrates
  the data somewhere else, then transfer again.
- Access groups cannot be shared with another developer's apps. Use iCloud Keychain
  with Associated Domains for that.

## Platform notes

- **watchOS.** Since watchOS 2 the watch has its own keychain, not joined to the
  iPhone's through access groups. Share through iCloud Keychain sync
  (`kSecAttrSynchronizable: true`, watchOS 6.2+) or send data over WatchConnectivity.
  Add Keychain Sharing to the WatchKit extension target, not the WatchKit app target.
- **Widgets.** Put the capabilities on the widget target itself. Keep auth tokens in
  the shared keychain group, not in `UserDefaults(suiteName:)`.
- **App Group containers** get only standard file protection
  (`NSFileProtectionCompleteUntilFirstUserAuthentication`). The Keychain is the safer
  home for credentials.

## Signing and distribution

- The entitlement format and Team ID prefix are the same for development, Ad Hoc,
  TestFlight and App Store builds.
- Each distribution profile decides which entitlements are allowed and embeds the
  `AppIdentifierPrefix`. Check that every one of them authorizes the groups.
- Accounts created before June 2011 may have per-app prefixes that differ from the
  Team ID. There are reports that adding a capability (Associated Domains, for example)
  to one target but not another changes that target's prefix and produces -34018. Keep
  capabilities identical across targets that share a group.

## When sharing breaks

| Status | Meaning |
| --- | --- |
| 0 `errSecSuccess` | Worked |
| -25299 `errSecDuplicateItem` | Item exists; update it instead |
| -25300 `errSecItemNotFound` | No match; before iOS 13 also the result for a group you are not entitled to |
| -34018 `errSecMissingEntitlement` | The target is not entitled to that group (iOS 13+) |
| -25308 `errSecInteractionNotAllowed` | Device locked and the item is WhenUnlocked |
| -50 `errSecParam` | Bad query: missing `kSecClass`, wrong value types, or sync combined with ThisDeviceOnly |
| -128 `errSecUserCanceled` | The user dismissed an authentication prompt |

From iOS 13 an unauthorized group reports -34018 rather than -25300.

Steps:

1. Inspect the entitlements actually signed into the build:
   `codesign -d --entitlements :- MyApp.app` and the same for `MyWidget.appex`. The
   arrays must share the group.
2. Decode the embedded profile: `security cms -D -i MyApp.app/embedded.mobileprovision`.
   Check `keychain-access-groups`, `com.apple.security.application-groups` and
   `com.apple.developer.team-identifier`.
3. Test on a physical device. The Simulator lacks real provisioning and can hide
   entitlement problems.
4. Watch the device in Console.app filtered on "keychain"; failed entitlement checks
   name the group.
5. Compare App ID prefixes across all targets.

| Test | Expected |
| --- | --- |
| Team-ID group item read by app, share extension, widget | All succeed |
| Item in a `group.` group | Works only with explicit `kSecAttrAccessGroup` |
| Synced item (not ThisDeviceOnly) | Appears on a second device on the same account |
| Extension without the entitlement | -34018 (or -25300 before iOS 13) |

## Threat model

- Synced items are end-to-end encrypted.
- Any device the user adds to their iCloud account can read, and could tamper with,
  synced items. Sync as little as possible and validate what you read back.
- Every app in a group can read every item in it. Use the narrowest group.
- Orphaned items persist until the device is erased; weigh that for very sensitive
  data.

## 2024 to 2026

- The `SecItem` API is unchanged. iOS 17/18 and macOS 14/15 added no sharing APIs, and
  there is still no Apple Swift-native Keychain wrapper.
- The Passwords app in iOS 18 and macOS Sequoia is a front end to iCloud Keychain; it
  changes no API or group behaviour.
- Passkey work (automatic upgrades, `ASCredentialExportManager` import and export)
  happens at the credential-manager level and adds no sharing mechanism.
- The `Always` accessibility constants have been deprecated since iOS 12. Use
  `AfterFirstUnlock` or `WhenPasscodeSetThisDeviceOnly`.

## Related

[keychain-fundamentals.md](keychain-fundamentals.md) for the SecItem contract,
[keychain-access-control.md](keychain-access-control.md) for accessibility classes,
[keychain-item-classes.md](keychain-item-classes.md) for primary keys,
[common-anti-patterns.md](common-anti-patterns.md) (#5, wrong data protection class),
and [credential-storage-patterns.md](credential-storage-patterns.md) for sharing tokens
with extensions.

## Checklist

- [ ] Code uses the full `TEAMID.` prefixed group string.
- [ ] Keychain Sharing is on every executable target.
- [ ] Keychain Sharing and App Groups formats are not mixed up; App Groups are not
      used for Keychain on macOS.
- [ ] Default group understood: keychain groups, then app id, then app groups; an app
      group is never the default.
- [ ] `kSecAttrAccessGroup` set explicitly on every add and query.
- [ ] Sync is opt-in, never with ThisDeviceOnly, and queries include
      `kSecAttrSynchronizable: true` or `kSecAttrSynchronizableAny`.
- [ ] macOS calls set `kSecUseDataProtectionKeychain: true`.
- [ ] Leftover items after reinstall handled with a UserDefaults flag and a scoped
      delete.
- [ ] Group changes done as read, add, then delete.
- [ ] Built entitlements checked with `codesign`; tested on a device.
- [ ] watchOS treated as a separate keychain; shared through sync or WatchConnectivity.
