Skip to main content
Version: 4.6.x.x RR (Android, iOS) / 4.7.x.x RR (Flutter) / 5.x.x.x RR (React Native)

iOS App Extensions

An iOS app extension runs in its own process and cannot reach into the process of the app that ships it. The Nevis Mobile Authentication SDK nevertheless allows an app extension to authenticate with the credentials the containing app already enrolled, without re-registering the user and without moving any keychain item or Secure Enclave key. The extension reaches the containing app's data through a shared keychain access group, and it resolves the keychain namespace, the device identifier and the facet ID from the containing app's bundle identifier instead of its own.

This chapter describes how to set that up. For the mechanism behind it, see iOS App Extensions in the concept chapter.

Availability
  • Requires iOS SDK 4.6.0 or later.
  • Available in Swift and Objective-C only. The Android, Flutter and React Native SDKs do not support app extension contexts.
  • The App Extension API is marked experimental in the API reference and may still change.

Prerequisites​

  • The containing app and the app extension belong to the same Apple Developer Team. Keychain sharing is scoped by the team prefix, so targets from different teams can never share keychain items.
  • The app extension declares the keychain access group that the containing app's SDK items already live in. See Determining the keychain access group.
  • The containing app has configured and initialized the SDK at least once on the device, running iOS SDK 4.6.0 or later. Until that has happened, the extension has nothing to operate on and initialization fails.
  • Both targets link iOS SDK 4.6.0 or later. See Building the extension target.

Enabling keychain sharing​

Keychain sharing is the only capability this feature needs, and in the common case only the app extension has to declare it. An app can always reach its own default access group, so the containing app needs no capability and no entitlement change. The extension is the target that has to be granted access to the group the containing app's SDK items already live in.

  1. Determine the containing app's default keychain access group. See Determining the keychain access group.
  2. In Xcode, select the app extension target, open Signing & Capabilities, and confirm the team is the one both targets use.
  3. Choose + Capability and add Keychain Sharing.
  4. Add the group determined in step 1, entered exactly.

Xcode writes the group into the extension's entitlements file:

Entitlements of the app extension
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)ch.nevis.exampleapp</string>
</array>
</dict>
</plist>

Xcode expands $(AppIdentifierPrefix) to the team prefix at build time, so the effective value in the example is <TeamPrefix>.ch.nevis.exampleapp, which is the containing app's App ID group.

If the containing app already declares a keychain access group

If the containing app declares a keychain-access-groups entitlement for other reasons, its default access group is the first entry of that entitlement, not its App ID. Declare that entry on the extension instead.

Do not add a new first entry to the containing app to make sharing easier. A new first entry becomes the new default, so items written afterwards land in a different group than the ones already enrolled. See Determining the keychain access group.

No App Group is required

App extension support does not require the App Groups capability. Everything the extension reads travels over the keychain. An App Group identifier is never the correct value to pass either, because an App Group can never be a default keychain access group. See the table below.

Determining the keychain access group​

This is the value most integrations get wrong, so it is worth spelling out.

An app's keychain access groups are, in this order:

  1. The entries of its keychain-access-groups entitlement.
  2. Its App ID, <TeamPrefix>.<bundleId>.
  3. The entries of its App Groups entitlement.

The first entry of that list is the app's default access group, and that is where every keychain item written without an explicit group lands. The SDK writes its items that way, so keychainAccessGroup must be the containing app's default access group, and that is also the group the extension has to declare in its own keychain-access-groups entitlement.

Containing app provisioningDefault access group the SDK usesValue to declare on the extension and pass as keychainAccessGroup
Neither Keychain Sharing nor App Groups presentThe App ID<TeamPrefix>.<containingBundleId>
App Groups present, no Keychain SharingThe App ID, because the App ID always exists and comes before the App Groups entries<TeamPrefix>.<containingBundleId>, not the group.… identifier
Keychain Sharing entitlement presentIts first keychain-access-groups entryThat entry, team-prefixed: <TeamPrefix>.<sharedGroup>
Keychain Sharing and App Groups both presentIts first keychain-access-groups entry, because keychain access groups come before App GroupsThat entry, team-prefixed: <TeamPrefix>.<sharedGroup>
An App Group is never the answer

As Apple states, an App Group can never be the default access group, because the App ID is always present and appears earlier in the list. This holds no matter what the containing app declares, so a group.… identifier is never the value to use.

If the containing app declares App Groups but no Keychain Sharing, its SDK items are in its App ID group. Passing the group.… identifier then resolves an access group the items are not in, and the extension initializes against an empty store: no accounts, no authenticators, and no error to go on.

This is a common trap in Apple Wallet integrations in particular, where both extension targets are required to carry an App Groups entitlement for unrelated reasons.

Do not change the default group of an app that is already in production

Adding a Keychain Sharing capability with a new group to an app that already has enrolled users makes that new group the default. Existing items stay behind in the old default group while new writes go to the new one.

This is the reason the containing app is left alone. Declare its current default group on the extension, and change nothing on the containing app, so the default does not move and no item is stranded.

Determining the application identifier​

applicationIdentifier is the containing app's bundle identifier, for example ch.nevis.exampleapp.

The SDK derives its keychain service names, the keychain item label, the device identifier and the facet ID from this value. An app extension has a bundle identifier of its own, usually the containing app's identifier with a suffix, so it must override the value to the containing app's identifier. Otherwise the SDK resolves a namespace nothing was ever written to.

caution

Pass the containing app's bundle identifier, never the extension's own and never Bundle.main.bundleIdentifier read from inside the extension. A wrong identifier produces the same silent symptom as a wrong access group: initialization succeeds and no registered account is found.

Configuring the SDK in the extension​

Only the extension is configured. Build a Configuration.AppExtension swift, objc from the two values determined above and pass it as the appExtension parameter of the Configuration swift, objc initializer that matches your backend. The containing app keeps its existing configuration unchanged and must not set the parameter.

App extension configuration
loading...

All three configuration initializers accept the parameter:

The value is readable afterwards through Configuration.appExtension swift, objc, which is not set for a containing app.

Everything else about the configuration, such as the backend host, the timeouts and the endpoint paths, is identical to a containing app configuration and is described in the Configuration chapter.

Initializing the SDK in the extension​

Initialization inside an extension follows the ordinary initialization flow. The SDK enforces one additional rule, in both directions, and rejects a mismatch instead of silently operating on the wrong data:

  • Inside an app extension the configuration is required. An app extension always operates as a companion of its containing app, so an extension that omits the value fails with InitializationError.AppExtensionConfigurationRequired swift, objc. An extension owning its own isolated credential store is not supported.
  • Outside an app extension it must not be set. Providing it in the containing app is a misconfiguration and fails with InitializationError.AppExtensionConfigurationNotInExtension swift, objc, rather than letting the containing app corrupt its own data.
The containing app must have initialized the SDK first

A third check applies only inside an extension, and it is the one integrators hit first. If the containing app has not published its shared data yet, initialization fails with InitializationError.ContainingAppInitializationRequired swift, objc.

Launch the containing app once, let it initialize the SDK, and retry in the extension. A single launch resolves it for good. Handle this error explicitly in the extension and guide the user to the containing app, because the extension cannot recover on its own.

Initialization in an app extension
loading...

For the remaining initialization errors, see SDK initialization errors.

Building the extension target​

There is one universal build of the SDK. The framework is compiled with APPLICATION_EXTENSION_API_ONLY = YES on all of its build configurations and references no app-extension-unavailable API, so the same artifact runs in an app and in an app extension. There is no separate extension-safe variant to choose, and no build setting to flip.

The extension target must link the framework, but must not embed it:

  • The containing app embeds NevisMobileAuthentication.xcframework once, as it does today.
  • In the app extension target, open General and, under Frameworks and Libraries, add the framework with Do Not Embed. At runtime the extension resolves the framework from the containing app bundle.
Do not embed the framework in the extension

If both the containing app and the extension embed the framework, the build fails with a Multiple commands produce … error naming a file inside the copy of NevisMobileAuthentication.framework that would be placed in the extension bundle. Two targets would write the same file to the same location.

Remove the framework from the app extension target's Embed Frameworks build phase, or set it to Do Not Embed under Frameworks and Libraries. Only the containing app embeds it.

What the extension can and cannot do​

A configured extension runs as a companion of its containing app. At initialization the SDK skips the housekeeping that only the owner of the credential store may perform, because performing it from a companion could corrupt or wipe the containing app's data:

  • The keychain integrity check and the reinstall reset it can trigger.
  • Data migrations.
  • Re-establishing the protection of the local store.

Everything the end user needs in an extension keeps working:

  • Reading local data (accounts, authenticators and device information) through LocalData swift, objc.
  • Authentication and transaction confirmation, in-app and out-of-band. There is nothing extension-specific about the flow: the system Face ID, Touch ID or device passcode sheet appears because the extension presents it from its own foreground user interface.
  • Removing a locked-out authenticator locally with LocalData.deleteAuthenticator swift, objc, so the user can re-register from the containing app.

Companion mode is not read-only. Registration and deregistration are not blocked by the API; they simply belong in the containing app, because they change the credential store the containing app owns. Route the user there instead of driving those operations from the extension.

Extension type caveats​

The general rule has two parts. The SDK initializes wherever the process is a genuine app extension of a containing app in the same Apple Developer Team. Interactive authentication additionally requires the extension to have foreground user interface, because the system presents the Face ID, Touch ID and device passcode sheets on behalf of that interface.

Extension typeSDK initializationInteractive authenticationNotes
Share extension✅✅The reference case, validated by Nevis.
Action extension✅✅Behaves like a Share extension.
Apple Wallet issuer provisioning, UI authorization handler (PKIssuerProvisioningExtensionAuthorizationProviding)✅✅The correct place to authenticate in a Wallet integration: it is a view controller with foreground user interface. Not validated end to end by Nevis, because the required entitlement works only with distribution provisioning profiles.
Apple Wallet issuer provisioning, non-UI handler (PKIssuerProvisioningExtensionHandler)❌ in practice❌Wallet expects status(completion:) to return within roughly 100 ms. SDK initialization plus authentication does not fit that budget, and the handler has no user interface to present a verification sheet from.
Widget (WidgetKit)✅❌A widget cannot present the Face ID or device passcode sheet. Deep-link into the containing app to authenticate.
Notification service extension, notification content extension✅❌No interactive verification user interface is available.
watchOS app❌ (not an app extension)Not applicableA watchOS app is a separate device with its own Secure Enclave. It cannot reach the iPhone keychain at all and therefore cannot use this mechanism. Proxy the request to the phone app, for example over WatchConnectivity, and run the SDK there.

Any extension type not listed here follows the same two-part rule. Types Nevis has not validated, such as custom keyboard extensions, should be treated as untested: verify them on a real device before relying on them. App Clips are not app extensions either. An App Clip is a separate app with its own bundle identifier and its own keychain, so the mechanism does not apply to them.

Application attestation​

If the authenticator policy requires Application Attestation, the only path open to an extension is the assertion path. Attestation keys are created and attested in the containing app. An extension can never create one, so the key must already exist before the extension runs.

Whether asserting from an extension works for your integration is something you have to establish yourself. See the warning at the end of this section.

Key creation never works in an app extension

Apple documents that App Attest key creation is unavailable to almost every extension type. According to Establishing your app's integrity, only Action, extensible SSO and watchOS extensions are supported, and all other extension types are not, even when isSupported reports true. Apple repeats this on DCAppAttestService.isSupported: generateKey fails when called from an app extension regardless of what isSupported returns.

Do not branch on isSupported inside an extension. Treat key creation as unavailable there and always create the key in the containing app.

The consequences for an integration are:

  • The containing app owns the key. It creates and attests the App Attest key during registration, and the SDK stores the resulting key identifier as a keychain item in the shared access group. The key identifier is an ordinary string, so it crosses to the extension like any other shared item.
  • The extension asserts with that key. The assertion is signed by the Secure Enclave key. As far as Nevis has been able to observe, its rpIdHash stays bound to the App ID of the app that created the key and is not recomputed from the calling process, so an assertion generated inside the extension still covers the containing app's App ID, which is what the server validates against.
  • The SDK reports the containing app's bundle identifier. It takes the value from the configured applicationIdentifier, which is exactly what the assertion's rpIdHash is over. Reporting the extension's own bundle identifier would fail that validation.
  • The SDK never creates or deletes an attestation key in companion mode. When no usable credential is available, it does not fall back to a fresh attestation, and it does not remove the stored credential, because that credential belongs to the containing app. It reports an attestation failure stating that a key attested by the containing app is required. Register or re-attest from the containing app to recover.
  • Validate on real hardware. App Attest requires the Secure Enclave and does nothing meaningful on the simulator.
Assertion in an app extension is not documented by Apple

Apple documents that key creation fails in an app extension, but it does not document the behavior of generateAssertion there, and it does not state that the rpIdHash of an assertion stays bound to the app that created the key. Neither the DeviceCheck documentation nor the DeviceCheck headers mark any API as unavailable to app extensions, so there is no compile-time or link-time guarantee either way.

The assertion path described here is based on observed behavior. It is consistent with Apple's server-side rule that rpIdHash matches the SHA256 of the App ID the key belongs to, but Apple gives no guarantee, and Nevis has not validated it.

Treat it as an option to evaluate rather than a supported contract. Before relying on it, verify on real hardware that an assertion produced in your extension type is accepted by your backend, and be prepared to fall back to authenticating in the containing app if it is not.

Retrieving the FacetId​

Use MobileAuthenticationClient.applicationFacetId swift, objc to obtain the facet ID the SDK actually uses. It is resolved per client, so inside an extension it returns the containing app's facet ID.

Retrieving the application facet ID
loading...
note

The process-global MetaData.applicationFacetId swift, objc is derived from the running bundle, so inside an extension it returns the extension's facet ID, which the backend does not know about. Use the per-client property instead. MobileAuthenticationClient.applicationFacetId is marked experimental in the API reference.

Because the SDK always presents the containing app's facet ID, the facet list configured in the backend needs only the containing app's ios:bundle-id: entry. Do not add an entry for the extension.

Existing applications​

There is no migration step, and no migration chapter to look for. An app that enrolled credentials before this feature shipped keeps working, and its existing users stay registered.

  • No keychain item is re-homed. The extension reads the items in the access group they already live in.
  • Secure Enclave keys are never moved or copied.
  • The containing app publishes the data the extension needs lazily, on its first SDK initialization after upgrading to iOS SDK 4.6.0 or later. Nothing has to be triggered explicitly.

The only ordering constraint is that the containing app must run once before the extension does. If the extension runs first, it fails fast with ContainingAppInitializationRequired instead of proceeding on unreachable data, and a single launch of the containing app resolves it for good.

tip

In a Wallet integration this constraint is met automatically: the user must open the issuer app at least once before Wallet surfaces the provisioning extensions.

Troubleshooting​

SymptomCauseFix
Initialization fails with AppExtensionConfigurationRequiredThe process is an app extension, but no Configuration.AppExtension was suppliedSupply the configuration. Every app extension runs as a companion and must declare the containing app's access group and identifier.
Initialization fails with AppExtensionConfigurationNotInExtensionA Configuration.AppExtension was supplied outside an app extensionRemove it from the containing app's configuration. Only the extension sets it.
Initialization fails with ContainingAppInitializationRequiredThe containing app has not initialized the SDK since the upgrade, so its shared data is not published yetLaunch the containing app once and let it initialize the SDK, then retry in the extension.
Initialization succeeds, but the extension reports no accountskeychainAccessGroup is not the containing app's default access group, for example an App Group identifier was passedRecompute the value with the table in Determining the keychain access group.
Initialization succeeds, but the extension reports no accountsapplicationIdentifier is the extension's own bundle identifierPass the containing app's bundle identifier. See Determining the application identifier.
Initialization succeeds, but the extension reports no accountsThe extension is missing the keychain-access-groups entitlement, or its entry does not match the containing app's default group character for characterRe-check Signing & Capabilities on the extension target and compare its generated entitlements file against the containing app's default group.
Initialization succeeds, but the extension reports no accountsThe two targets are signed by different Apple Developer TeamsMove both targets to the same team. The team prefix is part of every access group.
The build fails with a Multiple commands produce … error naming a file inside the embedded frameworkBoth the containing app and the extension embed the frameworkSet the framework to Do Not Embed in the extension target. See Building the extension target.
App Attest fails in the extension, reporting that a key attested by the containing app is requiredKey creation was attempted from the extension, or the stored key is no longer usable. Almost no extension type can create an App Attest keyCreate and attest the key in the containing app by registering there. The extension can only assert with an existing key. See Application attestation.