Skip to main content
Tutorials

iOS App Extension Signing: Share and Notification Extensions

Last updated

iOS app extension signing comes down to four rules. An extension’s bundle ID starts with the main app’s bundle ID plus a dot. Each extension has its own App ID and provisioning profile, signed by the same team with the same certificate as the main app. To share data, turn on App Groups on both sides with the same group ID. And the extension’s version and build numbers match the main app’s. The examples use a project called MyApp with a share extension and a notification service extension, and show how to lay out KXBuild’s keychain folder. Apple’s rules are as of October 2026.

The project in this tutorial already builds with KXBuild and installs on a device. If you’re not there yet, start with your first SwiftUI app. If certificates and profiles are new to you, read Bundle ID, App ID, certificates and provisioning profiles first.

What rules does iOS app extension signing follow?

An extension (.appex) is a separate program packaged inside the main app, with its own bundle ID and signature. A correctly signed main app doesn’t mean its extensions are signed correctly. Each one has to meet these rules on its own.

Rule Requirement What happens otherwise
Bundle ID prefix Starts with the main app’s bundle ID plus a dot, followed by exactly one more component, for example com.example.myapp.myappshare ITMS-90347 at upload
Unique bundle ID Every app, extension and framework in the package has a different bundle ID ITMS-90685 at upload
Own App ID Each extension registers its own explicit App ID in the developer account The build can’t find a provisioning profile
Own provisioning profile One profile per extension per distribution method Build or install fails
Same team, same certificate The main app and its extensions are signed with the same type of certificate from the same team Install reports invalid entitlements
Capabilities on both sides To share data, turn on App Groups for both the main app’s and the extension’s App IDs and select the same group Neither side sees the other’s data, or the profile lacks the capability
Matching versions The extension’s version and build numbers match the main app’s A version mismatch warning at upload

The full text of ITMS-90347 is Bad bundle identifier. The bundle identifier ‘…’ of the application extension ‘…’ should start with the application’s bundle identifier ‘…’ and not contain more than one period “.” after the application’s bundle ID. In other words, the prefix must be the main app’s bundle ID, followed by only one more period.

How do you add a share and a notification extension with KXBuild?

Use either of these.

  • VS Code extension. In the KXBuild view, right-click the MyApp target and choose KXBuild: Add Target…. Choose the Share type first, name it MyAppShare and enter group.com.example.myapp as the App Group. Then add another one, choose the Notification Service type, name it MyAppNotification and leave the App Group empty.
  • Command line. Run these two commands in the project folder.
1kxbuild project add-target -P .\MyApp.xcodeproj --type share --name MyAppShare --app-group group.com.example.myapp
2kxbuild project add-target -P .\MyApp.xcodeproj --type notification-service --name MyAppNotification

By default an extension’s bundle ID is the app’s bundle ID plus a dot and the lowercased name, here com.example.myapp.myappshare and com.example.myapp.myappnotification, which meets the prefix rule.

To set your own, use --bundle-id, which must also start with the app’s bundle ID plus a dot. When you enter an App Group, both the app and the share extension get the App Group entitlement. When the target is added, the message lists the App IDs to register and the capabilities to turn on. Follow it. See Add a target and the kxbuild command reference for all options and types.

Both extensions are built, packaged and installed with the app. No further project settings are needed.

Which App IDs and App Group do you need in the developer account?

Sign in to developer.apple.com, open Certificates, Identifiers & Profiles, and work through these steps in order.

  1. Create the App Group. On the Identifiers page, click +, choose App Groups, enter a description and the identifier group.com.example.myapp, and click Register. See Apple’s Register an app group.
  2. The main app’s App ID. Open com.example.myapp, check App Groups, click Configure and select the group you just created. If the app receives remote notifications, also check Push Notifications. Save.
  3. The share extension’s App ID. Click +, choose App IDs > App, choose Explicit for the bundle ID and enter com.example.myapp.myappshare. Check App Groups and select the same group.
  4. The notification service extension’s App ID. Register com.example.myapp.myappnotification the same way. It only modifies notification content before display, so it doesn’t need Push Notifications. Check App Groups only if it shares data with the app.

An App Group identifier is group. followed by a name. See Apple’s App Groups entitlement reference. The name must be identical in the code and entitlements of the app and every extension. Wildcard App IDs (com.example.*) can’t use App Groups, so every target with capabilities needs an explicit App ID.

If you turned on a capability after generating a profile, the existing profile doesn’t include it. Open the profile, click Edit, then Save without changes, and download it again.

Which provisioning profiles does an app with extensions need?

You need one per target per purpose. This table is the complete list for the three targets in this project. The names are the ones you enter when creating the profiles in the developer account.

Purpose Certificate MyApp MyAppShare MyAppNotification
Debug on a device Apple Development MyApp Dev MyAppShare Dev MyAppNotification Dev
Ad Hoc test distribution Apple Distribution MyApp AdHoc MyAppShare AdHoc MyAppNotification AdHoc
App Store and TestFlight Apple Distribution MyApp AppStore MyAppShare AppStore MyAppNotification AppStore

A few things are easy to miss.

  • Development and Ad Hoc profiles must include the same devices. Select the test devices in all three profiles. If only the app’s profile includes a device, the extension fails to install. When you add a test device, regenerate all three.
  • All three must use the same team and the same certificate. Create the Ad Hoc and App Store profiles with the same Apple Distribution certificate.
  • For development with no capabilities you can take a shortcut. One Development profile for a wildcard App ID (com.example.*) can sign the app and all its extensions. The share extension in this tutorial uses App Groups, so it can’t use a wildcard profile.

For the creation steps, see Apple’s Create a development provisioning profile, Create an ad hoc provisioning profile and Create an App Store provisioning profile.

How do you lay out the keychain folder, and how does matching work?

When you build with KXBuild on Windows or Linux, put certificates, profiles and the password file in the keychain certificate folder, following the rules in iOS app signing on Windows. With both the development and the App Store sets in place, it looks like this.

 1keychain\
 2├─ development.p12
 3├─ distribution.p12
 4├─ MyApp_Dev.mobileprovision
 5├─ MyAppShare_Dev.mobileprovision
 6├─ MyAppNotification_Dev.mobileprovision
 7├─ MyApp_AppStore.mobileprovision
 8├─ MyAppShare_AppStore.mobileprovision
 9├─ MyAppNotification_AppStore.mobileprovision
10└─ password.properties

At build time each target picks its own profile, following the selection rules.

  • Certificate type comes from CODE_SIGN_IDENTITY. Apple Development uses the development certificate and Apple Distribution uses the distribution certificate.
  • Team comes from DEVELOPMENT_TEAM, and only that team’s certificates and profiles are used.
  • Provisioning profile. With automatic signing, profiles are matched by the target’s bundle ID and team. The three targets have different bundle IDs, so each matches its own profile and you don’t have to set anything in the project. With manual signing, the profile is looked up by the name in PROVISIONING_PROFILE_SPECIFIER.

New projects and added extensions use automatic signing by default, so with the files in place you can build right away. If the certificate folder still has profiles downloaded before you turned on a capability, delete them so the build doesn’t match one without App Groups.

When Ad Hoc and App Store profiles are both present

Keep all six profiles in the folder. Choose the kind when you build: --export app-store uses only the App Store profiles and --export ad-hoc only the Ad Hoc ones, and each target still gets the profile for its own bundle ID. There’s nothing to set in the project.

1kxbuild build .\MyApp.xcodeproj -c Release --export ad-hoc
2kxbuild build .\MyApp.xcodeproj -c Release --export app-store

Don’t pass -D PROVISIONING_PROFILE_SPECIFIER=... on the command line. -D applies to every target, so the extensions get the app’s profile and fail with a bundle ID mismatch.

What does a minimal share extension look like?

A share extension appears in the system share sheet and receives text, links and images shared from other apps.

NSExtension in Info.plist

The MyAppShare/Info.plist generated when you added the target is already configured like this.

 1<key>NSExtension</key>
 2<dict>
 3	<key>NSExtensionAttributes</key>
 4	<dict>
 5		<key>NSExtensionActivationRule</key>
 6		<dict>
 7			<key>NSExtensionActivationSupportsImageWithMaxCount</key>
 8			<integer>1</integer>
 9			<key>NSExtensionActivationSupportsText</key>
10			<true/>
11			<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
12			<integer>1</integer>
13		</dict>
14	</dict>
15	<key>NSExtensionPointIdentifier</key>
16	<string>com.apple.share-services</string>
17	<key>NSExtensionPrincipalClass</key>
18	<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
19</dict>
  • NSExtensionPointIdentifier is the extension type. For share extensions it’s always com.apple.share-services.
  • NSExtensionPrincipalClass is the entry class. Without a storyboard, give the class name with the module name prefix.
  • NSExtensionActivationRule decides which content shows your extension. Here it’s up to 1 image, text, and up to 1 web URL. For types you don’t support, delete the key or set it to 0.

While debugging you can set NSExtensionActivationRule to the string TRUEPREDICATE so the extension appears for all content. Apple’s App Extension Programming Guide says apps containing TRUEPREDICATE are rejected in App Review, so change it back to specific rules before you submit. To edit these keys, double-click Info.plist to open it in the Property List Editor.

ShareViewController

Replace MyAppShare/ShareViewController.swift with the code below. It uses the system compose sheet and writes the text and URL to the App Group when the user taps Post.

 1import UIKit
 2import Social
 3import UniformTypeIdentifiers
 4
 5let appGroup = "group.com.example.myapp"
 6
 7var sharedDefaults: UserDefaults {
 8    UserDefaults(suiteName: appGroup) ?? .standard
 9}
10
11/// The system share compose sheet: a text field plus Post and Cancel, no storyboard needed
12class ShareViewController: SLComposeServiceViewController {
13
14    override func isContentValid() -> Bool {
15        // Allow posting only when the text is 200 characters or fewer
16        (contentText ?? "").count <= 200
17    }
18
19    override func didSelectPost() {
20        sharedDefaults.set(contentText, forKey: "sharedText")
21        sharedDefaults.set(Date().timeIntervalSince1970, forKey: "sharedAt")
22
23        // Find a URL in the shared attachments and save it to the App Group with the text
24        let providers = (extensionContext?.inputItems as? [NSExtensionItem] ?? [])
25            .flatMap { $0.attachments ?? [] }
26        guard let provider = providers.first(where: {
27            $0.hasItemConformingToTypeIdentifier(UTType.url.identifier)
28        }) else {
29            extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
30            return
31        }
32        provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in
33            if let url = item as? URL {
34                sharedDefaults.set(url.absoluteString, forKey: "sharedURL")
35            }
36            DispatchQueue.main.async {
37                self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
38            }
39        }
40    }
41
42    override func configurationItems() -> [Any]! {
43        []
44    }
45}

You must call completeRequest. It tells the system the share is finished and closes the sheet. After that the system may end the extension process at any time, so do long uploads in the app or with a background URLSession.

Read the shared content in the main app

The app reads from the same App Group. Replace the app’s ContentView.swift with the code below, which refreshes every time the app returns to the foreground.

 1import SwiftUI
 2
 3struct ContentView: View {
 4    @Environment(\.scenePhase) private var scenePhase
 5    @State private var sharedText = ""
 6    @State private var sharedURL = ""
 7
 8    private let defaults = UserDefaults(suiteName: "group.com.example.myapp") ?? .standard
 9
10    var body: some View {
11        List {
12            Section("Latest share") {
13                Text(sharedText.isEmpty ? "Nothing shared yet" : sharedText)
14                if !sharedURL.isEmpty {
15                    Text(sharedURL)
16                        .font(.footnote)
17                        .foregroundStyle(.secondary)
18                }
19            }
20        }
21        .onAppear(perform: load)
22        .onChange(of: scenePhase) { phase in
23            if phase == .active { load() }
24        }
25    }
26
27    private func load() {
28        sharedText = defaults.string(forKey: "sharedText") ?? ""
29        sharedURL = defaults.string(forKey: "sharedURL") ?? ""
30    }
31}

If either side lacks App Groups, or the group IDs are spelled differently, UserDefaults(suiteName:) doesn’t report an error. Each side just reads its own data, and the app never sees what was shared. The share extension uses the main app’s icon automatically, so you don’t need a separate one.

What does a minimal notification service extension look like?

A notification service extension receives a remote notification before it’s displayed and can change the title and body, decrypt content or download an image attachment. It shows no UI.

NSExtension in Info.plist

The generated MyAppNotification/Info.plist looks like this. The extension point is always com.apple.usernotifications.service.

1<key>NSExtension</key>
2<dict>
3	<key>NSExtensionPointIdentifier</key>
4	<string>com.apple.usernotifications.service</string>
5	<key>NSExtensionPrincipalClass</key>
6	<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
7</dict>

NotificationService

Replace MyAppNotification/NotificationService.swift with the code below. It adds a prefix to the title, and when the payload has an attachment-url, downloads the image as an attachment.

 1@preconcurrency import UserNotifications
 2
 3/// When aps in the payload has "mutable-content": 1, the system hands the notification here before displaying it
 4class NotificationService: UNNotificationServiceExtension {
 5
 6    var contentHandler: ((UNNotificationContent) -> Void)?
 7    var bestAttemptContent: UNMutableNotificationContent?
 8
 9    override func didReceive(_ request: UNNotificationRequest,
10                             withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
11        self.contentHandler = contentHandler
12        guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else {
13            contentHandler(request.content)
14            return
15        }
16        bestAttemptContent = content
17
18        // 1. Change the text: prefix the title to show the extension ran
19        content.title = "[New] " + content.title
20
21        // 2. Download an image attachment from the custom attachment-url field in the payload
22        guard let link = content.userInfo["attachment-url"] as? String,
23              let url = URL(string: link) else {
24            contentHandler(content)
25            return
26        }
27        URLSession.shared.downloadTask(with: url) { location, _, _ in
28            if let location {
29                let file = FileManager.default.temporaryDirectory
30                    .appendingPathComponent(UUID().uuidString)
31                    .appendingPathExtension(url.pathExtension)
32                if (try? FileManager.default.moveItem(at: location, to: file)) != nil,
33                   let attachment = try? UNNotificationAttachment(identifier: "image", url: file) {
34                    content.attachments = [attachment]
35                }
36            }
37            contentHandler(content)
38        }.resume()
39    }
40
41    override func serviceExtensionTimeWillExpire() {
42        // Called when the roughly 30 seconds are almost up. Deliver whatever content is ready
43        if let contentHandler, let bestAttemptContent {
44            contentHandler(bestAttemptContent)
45        }
46    }
47}

In the Swift 6 language mode, the compiler shows a Sendable warning for the contentHandler captured in the download callback. It doesn’t affect how the code runs.

Use this payload for testing. mutable-content must be 1, alert must have content, and attachment-url goes outside aps.

1{"aps":{"alert":{"title":"Your order has shipped","body":"Tap to track your package"},"mutable-content":1,"sound":"default"},"attachment-url":"https://example.com/images/parcel.jpg"}

Apple’s UNNotificationServiceExtension reference lists the conditions for the extension to run. The notification must be configured to display an alert, and aps must have mutable-content set to 1. Silent notifications and notifications with only a sound or badge don’t go through the extension.

didReceive has about 30 seconds. When time runs out the system calls serviceExtensionTimeWillExpire, and if neither method calls the completion handler, the system shows the original content. See Modifying content in newly delivered notifications.

The main app itself must be able to receive push notifications. Its App ID needs Push Notifications, its entitlements need aps-environment, and its code must register for push and get a device token. Follow iOS push notifications with APNs for that part, which also has the script for sending test pushes. To choose between .p8 and .p12 push credentials, see p8 or p12 for push notifications.

How do you keep extension version and build numbers in sync?

Extension targets have their own build settings. If you change the main app’s version and the extension doesn’t follow, the upload warns about a mismatch. The most reliable approach is to define MARKETING_VERSION and CURRENT_PROJECT_VERSION only at the project level (PROJECT), and not on any target.

Open the Project Settings Editor, switch the Build Settings page to the Levels view and search for the two settings. Confirm that the Target column is empty for MyApp, MyAppShare and MyAppNotification, and that the resolved value comes from the Project column. For the reasons and how to fix it, see iOS version and build numbers.

How do you build, install and package an app with extensions?

Debugging. Select your iPhone in the Status Bar and press F5. KXBuild builds the app and both extensions, signs each one, installs them and launches the app.

  • To test the share extension, open any web page in Safari, tap the share button and find MyApp in the app row. If it isn’t there the first time, scroll to the end, tap More and turn it on. After posting, go back to MyApp, and the list shows the text and URL you shared.
  • To test the notification service extension, get the device token as in the push tutorial and send the payload above. The title on the Lock Screen has the “[New]” prefix and shows an image thumbnail.

F5 debugs the app. The extensions run in their own processes, so breakpoints in the app don’t reach them. Keep extension logic simple and log with print or Logger. See Read iPhone device logs with Logger and os_log for how to read the logs.

Packaging. Use either of these.

  • VS Code extension. Run KXBuild: Build for App Store (.ipa) from the Command Palette or the project’s context menu.
  • Command line. Run this in the project folder to sign with the distribution certificate and App Store profiles.
1kxbuild build .\MyApp.xcodeproj -c Release --export app-store

The resulting build\MyApp.ipa contains both extensions, each signed with its own profile. For uploading and submitting for review, see Publish to the App Store on Windows. For an Ad Hoc build for test devices, use --export ad-hoc in place of --export app-store, and see Release and App Store submission.

How do you fix common iOS extension signing errors?

Error or symptom Cause Fix
No profiles for 'com.example.myapp.myappshare' were found The certificate folder has no profile for this extension, or the type is wrong Generate a profile of the right type for the bundle ID in the error and put it in keychain. See signing errors
The build says the profile doesn’t include App Groups (com.apple.security.application-groups) The profile wasn’t regenerated after the capability was turned on for the extension’s App ID, or a wildcard profile is in use Edit and save the profile, then download it again and replace the old one
Provisioning profile "…" has app ID "…", which does not match the bundle ID "…" With manual signing the extension was given the app’s profile name, or -D overrode it for every target Set PROVISIONING_PROFILE_SPECIFIER per target
Install fails with The executable was signed with invalid entitlements (0xe8008016) An extension was signed with capabilities beyond its profile, often because it used another team’s profile or its profile lacks a capability Use profiles from the same team with each target’s own bundle ID, and rebuild the whole project with KXBuild instead of re-signing an old package
Install fails with A valid provisioning profile for this executable was not found The extension’s Development or Ad Hoc profile doesn’t include this phone Select the device in all three profiles, download them again and rebuild
Upload fails with ITMS-90347 Bad bundle identifier The extension’s bundle ID doesn’t start with the main app’s, or has more than one extra period Change it to <main app bundle ID>.<name> and register the App ID and profile again
Upload fails with ITMS-90685 CFBundleIdentifier Collision Two bundles in the package share a bundle ID, for example the extension and the app, or the same framework embedded in both the app and an extension Give every target a unique bundle ID. Embed frameworks only in the app, and have extensions link without embedding
The upload warns that the extension’s version or build number doesn’t match the main app (often ITMS-90473) The extension target defines its own version Move both settings to the project level as described above
The app doesn’t appear in the share sheet The extension wasn’t installed, or the shared content doesn’t match NSExtensionActivationRule Confirm the extension was installed with F5, test with content the rule supports (text, URL, image), and turn it on under More if needed
The notification arrives but the extension has no effect The payload lacks mutable-content: 1, or alert is empty, or it’s a silent push Complete the payload as shown above, and check that the phone’s iOS version meets the extension’s deployment target

The full text of ITMS-90685 is CFBundleIdentifier Collision. There is more than one bundle with the CFBundleIdentifier value ‘…’ under the iOS application ‘…’. The message names the duplicated bundle ID, which tells you which two bundles to look at. For upload errors beyond iOS app extension signing, see the App Store ITMS error code reference. For other signing and install errors, see 16 common iOS code signing and .ipa install errors.

FAQ

Does an iOS extension need its own provisioning profile?

Yes. Each extension has its own bundle ID and App ID, and needs its own profile for each purpose (development, Ad Hoc, App Store). For development only, when neither the extensions nor the app use capabilities, one Development profile for a wildcard App ID can sign every target.

How must an extension’s bundle ID be named?

It must start with the main app’s bundle ID plus a dot, followed by exactly one more component, for example com.example.myapp.myappshare. Otherwise the upload to App Store Connect fails with ITMS-90347.

Does a notification service extension need the Push Notifications capability?

No. The push capability and aps-environment belong to the main app. The notification service extension only modifies content before display, so its App ID doesn’t need Push Notifications. Turn on App Groups for it only if it shares data with the app.

How do a share extension and the app share data?

Create an App Group in the developer account, turn on App Groups for both the main app’s and the extension’s App IDs with that group selected, and regenerate both profiles. In code, both sides read and write with UserDefaults(suiteName: "group.com.example.myapp"), with an identical group ID.

With several profiles in the keychain folder, how does KXBuild know which target gets which?

With automatic signing, each target matches a profile by its own bundle ID and team. The app and extensions have different bundle IDs, so each gets its own profile. When one bundle ID has both an Ad Hoc and an App Store profile, keep both: --export app-store picks the App Store profile and --export ad-hoc the Ad Hoc one.