Mastering iOS Code Signing, Provisioning Profiles, and the Build Process

A comprehensive deep-dive into how Apple's code signing system actually works, why it exists, and how to wield it confidently as an iOS engineer.


Table of Contents

  1. Introduction & Why This Matters
  2. The Big Picture: A Mental Model
  3. Cryptographic Foundations
  4. Apple's Trust Chain
  5. Certificates Deep Dive
  6. App IDs and Identifiers
  7. Registering Devices
  8. Provisioning Profiles Demystified
  9. Entitlements: The Bridge Between Code and Capabilities
  10. Anatomy of a Signed .app Bundle
  11. The iOS Build Process, Phase by Phase
  12. Code Signing in Practice (with codesign)
  13. Xcode Build Settings That Matter
  14. Automatic vs. Manual Signing
  15. Distribution Channels
  16. TestFlight and App Store Connect Internals
  17. CI/CD with Fastlane Match
  18. Debugging Common Signing Failures
  19. Advanced Topics: Extensions, App Groups, Push, On-Device Re-signing
  20. Best Practices and Mental Hygiene
  21. Glossary

1. Introduction & Why This Matters

If you've shipped any iOS app, you've stared at one of these in Xcode:

error: No profiles for 'com.example.MyApp' were found: Xcode couldn't find any iOS App Development provisioning profiles matching 'com.example.MyApp'.
error: Provisioning profile "iOS Team Provisioning Profile: com.example.MyApp" doesn't include signing certificate "Apple Development: Jane Doe (ABCD1234EF)".
error: A signed resource has been added, modified, or deleted.

The temptation is to click "Try Again" until it works. That works until it doesn't — and when it doesn't, you're staring at a build failure five minutes before a release deadline with no idea where to look.

This guide exists to make you the person on your team who knows what's going on under the hood. After reading it, you will:

This is not a "click here, then click there" tutorial. It's a systems guide. Where I show clicks, I also show what those clicks do under the hood.


2. The Big Picture: A Mental Model

Before we dive in, lock this mental model in your head. Code signing on iOS is the answer to a single question:

"Should this device run this binary?"

For the answer to be yes, four things must agree at install time and at every launch:

  1. The binary is signed by a key the device trusts. That key was issued by Apple to a real, paying member of the Apple Developer Program.
  2. The binary's bundle ID matches an App ID Apple knows about. That App ID is registered in your developer account.
  3. The device is permitted to run the binary. Either the device is in a list of authorized devices baked into a provisioning profile, or the build was distributed via the App Store / TestFlight (which permits any device).
  4. The capabilities the binary requests (entitlements) match the capabilities the App ID was authorized for. You can't just slap aps-environment = production into your binary and start receiving push notifications — Apple has to have agreed to that.

Everything else — certificates, profiles, build settings, codesign, the dance Xcode does on every build — is machinery in service of those four checks.

Keep that mental model running. When something breaks, ask: which of those four agreements is failing?


3. Cryptographic Foundations

Code signing is built on public-key cryptography (also called asymmetric cryptography). If you already know RSA / ECDSA, skim this; if not, internalize it because it shows up everywhere.

3.1 Key Pairs

You generate two mathematically linked keys:

The math (RSA modular arithmetic, or elliptic curve operations for ECDSA) ensures that you cannot derive the private key from the public key in any reasonable amount of time.

For code signing, the relevant operation is signing:

  1. Hash the binary (SHA-256 is what Apple uses today).
  2. Encrypt that hash with the private key — the result is the signature.
  3. Anyone with the public key can decrypt the signature, recompute the hash from the binary, and check that they match. If they do, the binary is authentic and unmodified.

When you "request a certificate" from Apple, you generate a key pair locally. Your private key never leaves your Mac (well — it lives in the Keychain). What goes to Apple is a Certificate Signing Request (CSR), which contains your public key and some identity metadata.

3.2 Generating a CSR by Hand

Xcode hides this, but you can do it yourself:

# Generate a 2048-bit RSA private key
openssl genrsa -out private.key 2048

# Generate a CSR using that private key
openssl req -new -key private.key -out request.csr \
  -subj "/emailAddress=jane@example.com/CN=Jane Doe/C=US"

The .csr file is what you'd upload to the Apple Developer portal under Certificates → +. Apple takes your public key from the CSR, packages it with metadata about you (your team, validity period, your role), signs that whole package with Apple's private key, and gives you back a .cer file. That .cer is your certificate.

3.3 Certificates ≠ Private Keys

This is the single most-confused point in iOS code signing.

When you see the keychain entry like this:

▶ Apple Development: Jane Doe (ABCD1234EF)
   ▶ private key

The disclosure triangle expanding to reveal a private key is the sign that this Mac can actually sign builds with that certificate. Without that private-key child entry, the certificate is a paperweight on this machine.

This is also why you can Export → Personal Information Exchange (.p12) from Keychain Access — the .p12 is the bundle of certificate + private key, encrypted under a password you choose. Backing up that .p12 is how teams share signing identities across Macs without trusting each Mac to generate its own certificate.

3.4 Hashing and Integrity

Every bundled resource — your Info.plist, your asset catalog, your Mach-O executable, every framework — gets hashed during signing. The hashes are stored in a file called CodeResources inside _CodeSignature/ in the app bundle. The signature covers those hashes.

The implication: if anything in the bundle changes after signing, the signature breaks. This is why you cannot just edit a plist inside a signed .ipa. It's why "A sealed resource is missing or invalid" errors appear when you accidentally drop a .DS_Store into a built .app.


4. Apple's Trust Chain

Your certificate isn't trusted in a vacuum. It's trusted because Apple says so, and Apple's say-so is itself anchored in a root certificate that ships in iOS.

4.1 The Chain

Apple Inc. Root CA                     (self-signed, baked into iOS)
        │
        ▼
Apple Worldwide Developer
Relations Certification Authority      (the "WWDR" intermediate)
        │
        ▼
Apple Development: Jane Doe (...)      (your certificate)
        │
        ▼
your signed app

When iOS verifies a binary, it walks this chain upward. Each link is a certificate signed by the link above it. The walk terminates at the Apple Root CA, which iOS trusts unconditionally because it's burned into the OS image.

4.2 Why You Sometimes Need to Reinstall WWDR

Apple periodically rotates its WWDR intermediate certificate. When the old one expires (or when a new device or fresh macOS install lacks the new one), you'll see:

This certificate was signed by an unknown authority.

Fix: download the current WWDR intermediate from https://www.apple.com/certificateauthority/ and double-click it to install into the System keychain.

You can verify the chain on your own machine:

# List all WWDR-related entries in your keychain
security find-certificate -a -c "Apple Worldwide Developer Relations" \
  /Library/Keychains/System.keychain

# Inspect the validity period of one
security find-certificate -c "Apple Worldwide Developer Relations" -p \
  | openssl x509 -text -noout | grep -A 2 Validity

4.3 Two Sub-Chains You'll Encounter

Apple actually issues your certificate from one of several intermediates depending on what you're signing:

The takeaway: when you see "G6" or "G3" in a cert name, don't panic — it's the same chain, just a newer link.


5. Certificates Deep Dive

Apple issues several types of certificates. The names changed a few years ago, so you'll see both old and new in tutorials and forums.

5.1 The Types You'll Actually Use

Modern Name Old Name What It Signs Where It Comes From
Apple Development iOS Development Builds destined for your registered devices, including Simulator-on-device parity testing Developer portal, automatic via Xcode
Apple Distribution iOS Distribution Builds destined for App Store, TestFlight, or Ad Hoc Developer portal, requires team agent or admin
Apple Push Services n/a Token-based or certificate-based push notifications (server-side, not the app itself) Developer portal, used by your backend
Mac Installer Distribution n/a .pkg installers for Mac App Store macOS only — out of scope here
Developer ID Application n/a Direct distribution outside the Mac App Store macOS only — out of scope here

For a typical iOS team, you only deal with Apple Development (for xcodebuild to local devices) and Apple Distribution (for releases).

5.2 What's Inside a .cer

A .cer is an X.509 certificate. You can dump its contents:

openssl x509 -inform der -in ios_development.cer -text -noout

Output (abbreviated):

Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number: 6F:..:..:..
        Signature Algorithm: sha256WithRSAEncryption
        Issuer: CN = Apple Worldwide Developer Relations Certification Authority,
                OU = G3, O = Apple Inc., C = US
        Validity
            Not Before: Mar 15 09:00:00 2025 GMT
            Not After : Mar 15 09:00:00 2026 GMT
        Subject: UID = ABCD1234EF, CN = Apple Development: Jane Doe (XYZ987QWER),
                 OU = TEAMID1234, O = Jane Doe, C = US
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                Public-Key: (2048 bit)
                ...
        X509v3 extensions:
            X509v3 Extended Key Usage: critical
                Code Signing
            ...

Things to notice:

5.3 The Two Ten-Character IDs You'll Confuse

This is a setup for confusion, so commit it now:

When you read profile or build-setting metadata, look at which 10-character ID is being referenced.

5.4 The Modern "Single Cert" Era

Pre-Xcode 11, you had separate iOS Development and iOS Distribution certificates. Apple has since unified things:

This is why if you generate a new cert today, you don't see "iOS Development" anymore. Old profiles that reference iOS-only certs still exist, which can cause subtle "no signing certificate" errors when mixing old and new.

5.5 Listing Your Signing Identities

# All identities valid for code signing on this Mac
security find-identity -v -p codesigning

# Output looks like:
#  1) F8C9...A1  "Apple Development: Jane Doe (XYZ987QWER)"
#  2) 3DA2...0B  "Apple Distribution: Acme Inc. (TEAMID1234)"
#     2 valid identities found

The hex prefix is the SHA-1 fingerprint of the certificate. Some tools (especially older Fastlane plugins) want this fingerprint instead of the human name. The 40-character hex is unique to this exact certificate on this exact Mac.


6. App IDs and Identifiers

An App ID is Apple's record of "this bundle identifier exists, and it's allowed to use these capabilities." It lives on Apple's developer portal, not on your machine.

6.1 Explicit vs. Wildcard App IDs

Two flavors:

Rule of thumb: the moment you add any capability, you need an explicit App ID.

6.2 The Bundle ID Lives in Two Places

Your Info.plist ships with CFBundleIdentifier, which Xcode populates from the build setting PRODUCT_BUNDLE_IDENTIFIER:

<!-- Inside the built Info.plist -->
<key>CFBundleIdentifier</key>
<string>com.acme.MyApp</string>
// In the .xcodeproj's build settings
PRODUCT_BUNDLE_IDENTIFIER = com.acme.MyApp

For code signing to succeed, this bundle ID must be a substring-or-exact match against the App ID registered in the provisioning profile. Specifically:

6.3 Reverse-DNS Style Convention

Bundle IDs use reverse-DNS notation by convention (com.acme.MyApp, not MyApp). This isn't enforced by the tooling, but Apple's portal expects it and many tools assume it. Pick a domain you control as the prefix.

For app extensions (Today widgets, Share extensions, Notification Service Extensions, etc.), the bundle ID must be prefixed by the host app's bundle ID:

Host app:   com.acme.MyApp
Widget:     com.acme.MyApp.Widget   ✓
Widget:     com.acme.WidgetForApp   ✗  (not a valid extension bundle ID)

Each extension needs its own App ID and its own provisioning profile. We'll cover this in section 19.


7. Registering Devices

For development and Ad Hoc builds, every device that will run the build must be in the App ID's profile by UDID (Unique Device Identifier).

7.1 Finding a UDID

Three ways:

  1. Xcode → Window → Devices and Simulators → select device → "Identifier" field.
  2. Finder (modern macOS) → connect device → click the device in the sidebar → click the model name under the device name to cycle through UDID, ECID, IMEI, etc.
  3. Terminal with idevice_id:
    brew install libimobiledevice
    idevice_id -l   # lists UDIDs of connected devices
    

A UDID is a 25-character or 40-character hex string (Apple changed the format with newer devices). Example: 00008101-001A2B3C4D5E001E.

7.2 Adding a Device

In the Apple Developer portal: Devices → +. Paste the UDID, give it a label.

A team has a per-membership-year limit of devices per platform: 100 iPhones, 100 iPads, 100 Macs, 100 Apple TVs, 100 Apple Watches per membership year. Devices can be disabled at any time, but the slot only frees up at annual renewal — important to remember when burning through slots in a large QA org.

Xcode 11+ can register a connected device automatically if you're using automatic signing.

7.3 Devices Are Embedded in Profiles, Not Looked Up Dynamically

This is the gotcha: a provisioning profile is a snapshot of the device list at the moment the profile was generated. If you add a new device to your team after generating the profile, that device is not yet authorized — you must regenerate (or, in Xcode automatic mode, re-fetch) the profile.

This is why CI builds for new beta testers fail with "device not in profile" until someone re-runs match or re-downloads.


8. Provisioning Profiles Demystified

A provisioning profile (.mobileprovision) is the central artifact that ties everything together. It's a signed property list that says, in effect:

"This App ID, signed by these certificates, may run on these devices, with these entitlements, between these dates."

8.1 Profile Types

Type Purpose Devices Distributed Via
iOS App Development Run builds on registered dev/test devices Listed in profile xcodebuild, Xcode "Run"
Ad Hoc Distribute to ≤100 registered testers per year, outside TestFlight Listed in profile .ipa to install via tools (Xcode, Apple Configurator, Diawi)
App Store Submit to App Store / TestFlight None — gated by App Store Connect Upload via Transporter / xcodebuild -exportArchive
In-House (Enterprise) Distribute internally to any employee device Any device that trusts your org MDM or direct .ipa

8.2 What's in a Profile

A .mobileprovision file is a CMS (PKCS#7) signed plist. The signature wrapper proves Apple issued it. The plist inside contains the actual policy.

To inspect one yourself:

security cms -D -i MyApp_Development.mobileprovision

Output (abbreviated):

<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
    <key>AppIDName</key>
    <string>MyApp</string>

    <key>ApplicationIdentifierPrefix</key>
    <array><string>TEAMID1234</string></array>

    <key>CreationDate</key>
    <date>2026-01-15T10:00:00Z</date>

    <key>ExpirationDate</key>
    <date>2027-01-15T10:00:00Z</date>

    <key>Name</key>
    <string>iOS Team Provisioning Profile: com.acme.MyApp</string>

    <key>TeamIdentifier</key>
    <array><string>TEAMID1234</string></array>

    <key>TeamName</key>
    <string>Acme Inc.</string>

    <key>Platform</key>
    <array><string>iOS</string></array>

    <key>UUID</key>
    <string>F8E9D7C6-1234-5678-90AB-CDEF12345678</string>

    <key>DeveloperCertificates</key>
    <array>
      <data>MIIFkzC...base64-encoded DER of cert 1...</data>
      <data>MIIFkzC...base64-encoded DER of cert 2...</data>
    </array>

    <key>Entitlements</key>
    <dict>
      <key>application-identifier</key>
      <string>TEAMID1234.com.acme.MyApp</string>
      <key>com.apple.developer.team-identifier</key>
      <string>TEAMID1234</string>
      <key>get-task-allow</key>
      <true/>
      <key>aps-environment</key>
      <string>development</string>
      <key>com.apple.developer.associated-domains</key>
      <array><string>*</string></array>
    </dict>

    <key>ProvisionedDevices</key>
    <array>
      <string>00008101-001A2B3C4D5E001E</string>
      <string>00008110-000234567890ABCD</string>
    </array>
</dict>
</plist>

Field-by-field translation:

8.3 Where Profiles Live

On a Mac, downloaded profiles live here:

# Modern macOS (Xcode 16+)
~/Library/Developer/Xcode/UserData/Provisioning Profiles/

# Older macOS
~/Library/MobileDevice/Provisioning Profiles/

Each file is named by its UUID:

F8E9D7C6-1234-5678-90AB-CDEF12345678.mobileprovision

Xcode reads this directory to populate the "Provisioning Profile" picker. You can copy a .mobileprovision here to make it available, or delete one to invalidate it locally.

Quick inspection script you'll want in your ~/.zshrc:

function profile() {
  for f in ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/*.mobileprovision \
           ~/Library/MobileDevice/Provisioning\ Profiles/*.mobileprovision 2>/dev/null; do
    [[ -f "$f" ]] || continue
    name=$(security cms -D -i "$f" 2>/dev/null \
             | /usr/libexec/PlistBuddy -c "Print :Name" /dev/stdin)
    expiry=$(security cms -D -i "$f" 2>/dev/null \
             | /usr/libexec/PlistBuddy -c "Print :ExpirationDate" /dev/stdin)
    appid=$(security cms -D -i "$f" 2>/dev/null \
             | /usr/libexec/PlistBuddy -c "Print :Entitlements:application-identifier" /dev/stdin)
    echo "$(basename $f .mobileprovision)  $appid  ($name)  expires $expiry"
  done
}

Run profile and you'll see every cached profile, what it's for, and when it expires.

8.4 Embedded vs. Installed

When Xcode builds and signs your .app, it copies the active profile into the bundle as embedded.mobileprovision:

MyApp.app/embedded.mobileprovision

At install time, iOS reads this embedded profile, checks the device's UDID against ProvisionedDevices, and stores the profile in the device's profile database. From then on, whenever your app launches, iOS verifies the binary against the rules in that profile.

You can view profiles installed on a device under Settings → General → VPN & Device Management → Provisioning Profiles (the menu has shuffled around iOS versions, but it's always under General).

8.5 Profile Expiration

This is why teams sometimes find that yesterday's TestFlight build no longer launches: the profile actually expired, and the embedded profile has hit its ExpirationDate.


9. Entitlements: The Bridge Between Code and Capabilities

Entitlements are the system that links your App ID's authorized capabilities to what your binary actually claims. They're the "what is this app allowed to do?" answer.

9.1 The .entitlements File

Xcode generates a MyApp.entitlements file when you add your first capability. It's a plist:

<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
    <key>aps-environment</key>
    <string>development</string>

    <key>com.apple.developer.associated-domains</key>
    <array>
      <string>applinks:acme.com</string>
      <string>webcredentials:acme.com</string>
    </array>

    <key>com.apple.security.application-groups</key>
    <array>
      <string>group.com.acme.shared</string>
    </array>

    <key>com.apple.developer.icloud-container-identifiers</key>
    <array>
      <string>iCloud.com.acme.MyApp</string>
    </array>

    <key>keychain-access-groups</key>
    <array>
      <string>$(AppIdentifierPrefix)com.acme.MyApp</string>
    </array>
</dict>
</plist>

The build setting CODE_SIGN_ENTITLEMENTS points at this file:

CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements

During the Sign build phase, codesign reads the entitlements file and embeds the entitlements into the Mach-O LC_CODE_SIGNATURE load command of your executable.

9.2 The Subset Rule

This is the rule:

Your binary's embedded entitlements must be a subset of the profile's entitlements, which must be a subset of the App ID's enabled capabilities.

If you write aps-environment = production in your .entitlements but the profile only grants aps-environment = development, the build fails:

error: Provisioning profile "..." doesn't include the aps-environment entitlement.

If you add an entitlement Xcode hasn't surfaced as a capability — say, by hand-editing the plist — the build fails the same way. The fix is to enable the capability on the App ID (which updates what entitlements the profile grants) and then regenerate the profile.

9.3 Common Entitlements You'll See

Key Meaning
application-identifier TEAMID.bundleid — every app gets this automatically
com.apple.developer.team-identifier Your team ID — automatic
get-task-allow true permits debugger attach; must be false for App Store / TestFlight
aps-environment development or production for push notifications
com.apple.security.application-groups App Group IDs for sharing data between extensions/apps
com.apple.developer.associated-domains Universal Links and Web Credentials domains
com.apple.developer.icloud-services iCloud capability
com.apple.developer.icloud-container-identifiers iCloud container IDs
keychain-access-groups Keychain sharing group(s)
com.apple.developer.in-app-payments Apple Pay merchant IDs
com.apple.developer.applesignin Sign in with Apple
com.apple.developer.networking.wifi-info Read current Wi-Fi SSID
com.apple.developer.networking.HotspotConfiguration Configure Wi-Fi hotspot
com.apple.developer.kernel.increased-memory-limit Higher RAM ceiling for media apps

The $(AppIdentifierPrefix) variable used in keychain-access-groups is interesting — Xcode rewrites it at build time to the team identifier prefix TEAMID1234.. This means the entitlement value differs between teams, which is why apps signed by Team A can't read Team B's keychain group.

9.4 Inspecting a Built Binary's Entitlements

codesign -d --entitlements :- MyApp.app

The :- says "print as XML to stdout, no header." Output is the same plist that's embedded in the Mach-O.

For just the executable inside the bundle:

codesign -d --entitlements :- MyApp.app/MyApp

This is gold for debugging "why is my app not getting push notifications?" — check whether aps-environment is even there.


10. Anatomy of a Signed .app Bundle

Let's open up a built, signed .app and walk through it.

MyApp.app/
├── MyApp                      # the Mach-O executable (signed)
├── Info.plist                 # bundle metadata
├── PkgInfo                    # legacy (4-char type + 4-char creator)
├── embedded.mobileprovision   # the profile, copied in
├── _CodeSignature/
│   └── CodeResources          # plist mapping every file to its hash
├── Frameworks/                # any embedded frameworks
│   └── Alamofire.framework/
│       ├── Alamofire          # signed
│       └── _CodeSignature/
│           └── CodeResources
├── PlugIns/                   # app extensions
│   └── MyAppWidget.appex/
│       ├── MyAppWidget        # signed
│       ├── Info.plist
│       ├── embedded.mobileprovision   # extension's own profile
│       └── _CodeSignature/
│           └── CodeResources
├── Assets.car                 # compiled asset catalog
├── Base.lproj/                # localized resources
│   ├── LaunchScreen.storyboardc
│   └── Main.storyboardc
├── en.lproj/
│   └── Localizable.strings
└── icons / images / fonts ...

Key observations:

10.1 The CodeResources File

plutil -p MyApp.app/_CodeSignature/CodeResources | head -30
{
  "files" => {
    "Assets.car" => {data: <hash>},
    "Info.plist" => {data: <hash>, hash2: <hash>},
    "Base.lproj/Main.storyboardc/Info.plist" => {data: <hash>},
    ...
  }
  "files2" => {
    ...
  }
  "rules" => { ... }
  "rules2" => { ... }
}

files and rules are the v1 manifest; files2 and rules2 are v2 (post-iOS 9). Both are present for compatibility.

10.2 Reading the Mach-O Signature

The signature itself is a load command appended to the Mach-O binary. Inspect it with:

codesign -dvvv MyApp.app
Executable=/path/MyApp.app/MyApp
Identifier=com.acme.MyApp
Format=app bundle with Mach-O thin (arm64)
CodeDirectory v=20500 size=12345 flags=0x0(none) hashes=384+7 location=embedded
Hash type=sha256 size=32
CandidateCDHash sha256=...
Signature size=4567
Authority=Apple Development: Jane Doe (XYZ987QWER)
Authority=Apple Worldwide Developer Relations Certification Authority
Authority=Apple Root CA
Signed Time=Jan 15, 2026, 10:30:00 AM
Info.plist entries=42
TeamIdentifier=TEAMID1234
Sealed Resources version=2 rules=...
Internal requirements count=1 size=...

Useful signals:


11. The iOS Build Process, Phase by Phase

Now let's connect everything to what happens when you press ⌘B.

11.1 The High-Level Flow

┌─ Resolve dependencies (SPM, Cocoapods, Carthage)
├─ Run "Pre-action" scripts (if any)
├─ For each target, in dependency order:
│    ├─ Compile Sources         (Swift / Obj-C → .o)
│    ├─ Compile Asset Catalogs  (Assets.xcassets → Assets.car)
│    ├─ Compile Storyboards     (.storyboard → .storyboardc)
│    ├─ Process Info.plist
│    ├─ Link                    (.o + libs → Mach-O)
│    ├─ Copy Bundle Resources   (everything else)
│    ├─ Embed Frameworks        (third-party + dynamic)
│    ├─ Embed App Extensions    (.appex bundles)
│    └─ CodeSign                (the part we care about)
├─ Strip debug symbols (Release)
├─ Run "Post-action" scripts (if any)
└─ Final .app or .xcarchive

11.2 What Code Signing Actually Does (Phase Detail)

The "CodeSign" phase happens bottom-up: frameworks first, then extensions, then the main app. This ordering matters because the outer signature seals the hashes of the inner signed contents.

For each item to be signed:

  1. Resolve the signing identity. Read CODE_SIGN_IDENTITY build setting. Look up matching certificate in keychain.
  2. Resolve the provisioning profile. Read PROVISIONING_PROFILE_SPECIFIER. Look up matching profile in ~/Library/Developer/Xcode/UserData/Provisioning Profiles/.
  3. Verify identity is in profile. The cert must be in the profile's DeveloperCertificates array.
  4. Verify entitlements compatibility. Read CODE_SIGN_ENTITLEMENTS. Check every entry is permitted by the profile.
  5. Copy the profile in. Place at MyApp.app/embedded.mobileprovision.
  6. Compute resource hashes. Walk every file in the bundle (excluding the executable itself and _CodeSignature/), hash each, write _CodeSignature/CodeResources.
  7. Embed entitlements into Mach-O. Add as a blob in the code signature.
  8. Hash the Mach-O text & data segments.
  9. Sign the code directory. RSA-sign the hash with the private key.
  10. Append the signature to the Mach-O.

This is exactly what the codesign command-line tool does. Xcode just orchestrates it across the bundle.

11.3 Useful Build Logs

In Xcode's Report Navigator (⌘9), expand the build log for a fresh build. Find the "CodeSign" entries. They look like:

CodeSign /Users/jane/Library/Developer/Xcode/DerivedData/.../MyApp.app
    cd /Users/jane/Projects/MyApp
    export CODESIGN_ALLOCATE=/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/codesign_allocate
    Signing Identity: "Apple Development: Jane Doe (XYZ987QWER)"
    Provisioning Profile: "iOS Team Provisioning Profile: com.acme.MyApp"
                          (F8E9D7C6-1234-5678-90AB-CDEF12345678)

    /usr/bin/codesign --force --sign F8C9...A1 \
        --entitlements /...DerivedData/.../MyApp.app.xcent \
        --timestamp=none --generate-entitlement-der \
        /...DerivedData/.../MyApp.app

Observe: Xcode invokes /usr/bin/codesign directly. If you replicate that command in your terminal, you sign the same way Xcode does. This is the seam to inject for CI builds, custom signing flows, or post-build re-signing.

11.4 The xcent File

You'll notice MyApp.app.xcent referenced. This is a binary plist that Xcode writes during the build by combining your .entitlements file with build-setting overrides (especially the application-identifier and team identifier, which depend on the active profile). It's the actual entitlements that get embedded — your source .entitlements is just an input.

You can read it:

plutil -convert xml1 -o - /path/to/MyApp.app.xcent

When debugging "wrong entitlements" errors, the .xcent is the source of truth, not the source .entitlements file.


12. Code Signing in Practice (with codesign)

The codesign tool is your friend. Learn it.

12.1 Verifying a Signature

# Quick check
codesign -v MyApp.app

# Verbose
codesign -vvv --deep --strict MyApp.app

# Verify against a particular requirement
codesign -v -R="anchor apple generic" MyApp.app

Exit code 0 = valid. Anything else = something is wrong, and -vvv will tell you what.

12.2 Inspecting Signature Details

codesign -dvvv MyApp.app

Shows the team ID, signing authority chain, code directory hash, sealed resources count, etc. (Same output we covered in 10.2.)

12.3 Inspecting Embedded Entitlements

# Old-style XML output (works everywhere)
codesign -d --entitlements :- MyApp.app

# DER output (newer iOS versions actually use DER)
codesign -d --entitlements - --xml MyApp.app

12.4 Re-Signing a Built .app

This is what happens when you take an Ad Hoc .ipa and want to re-sign it for a different team or different profile (CI pipelines, white-label distribution, post-build patches):

# 1. Unzip the .ipa
unzip MyApp.ipa
# Now you have Payload/MyApp.app

# 2. Replace the embedded profile
cp NewProfile.mobileprovision Payload/MyApp.app/embedded.mobileprovision

# 3. Re-sign all embedded frameworks first (bottom-up!)
for fw in Payload/MyApp.app/Frameworks/*.framework; do
  codesign --force --sign "Apple Distribution: Acme Inc. (TEAMID1234)" "$fw"
done

# 4. Re-sign any extensions
for ext in Payload/MyApp.app/PlugIns/*.appex; do
  codesign --force --sign "Apple Distribution: Acme Inc. (TEAMID1234)" \
           --entitlements ext.entitlements "$ext"
done

# 5. Re-sign the main app
codesign --force --sign "Apple Distribution: Acme Inc. (TEAMID1234)" \
         --entitlements app.entitlements Payload/MyApp.app

# 6. Repackage
zip -qr MyApp-resigned.ipa Payload

Critical detail: bottom-up signing. If you sign the main app first, then change a framework, the main app's signature is invalidated.

12.5 Useful codesign Flags Cheat Sheet

Flag Purpose
-s <identity> Sign with the given identity (CN or SHA-1)
-f / --force Replace existing signature
-d Display info (doesn't sign)
-v Verify
-vvv Verbose verify
--deep Recurse into bundle (frameworks, extensions); largely deprecated in favor of explicit nested signing
--entitlements <path> Use given entitlements file
--timestamp=none Skip timestamping (iOS doesn't require it)
--preserve-metadata=identifier,entitlements Re-sign without replacing entitlements
--strict Stricter verification rules
--remove-signature Strip a signature off (useful for lipo-edited binaries)

13. Xcode Build Settings That Matter

These are the build settings code signing actually depends on. You can find each by typing its name into the search field on the Build Settings tab.

DEVELOPMENT_TEAM = TEAMID1234

Your team ID. Required for both automatic and manual signing.

CODE_SIGN_IDENTITY = "Apple Development"
CODE_SIGN_IDENTITY[sdk=iphoneos*] = "Apple Distribution: Acme Inc. (TEAMID1234)"

The identity to sign with. The [sdk=...] conditional lets you sign differently per SDK — typical for distribution. Note: "Apple Development" (with no specific name) tells Xcode "any Apple Development cert in my keychain" — useful for shared projects.

CODE_SIGN_STYLE = Automatic | Manual

Whether Xcode manages profiles or you do.

PROVISIONING_PROFILE_SPECIFIER = "iOS Team Provisioning Profile: com.acme.MyApp"

What profile to use. Accepts a profile name or a UUID. Names are nicer in source control (UUIDs change when profiles are regenerated).

PROVISIONING_PROFILE = F8E9D7C6-1234-5678-90AB-CDEF12345678

Deprecated — use the specifier. Still seen in older .pbxprojs.

CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements

Path to the entitlements file. Different per target — extensions have their own.

PRODUCT_BUNDLE_IDENTIFIER = com.acme.MyApp

The bundle ID. Must match the App ID referenced by the profile.

13.4 Resource Rules and Resource Building

ENABLE_BITCODE = NO

Bitcode is deprecated as of Xcode 14. Set to NO; older projects often still have YES, which can cause distribution failures.

STRIP_INSTALLED_PRODUCT = YES
DEBUG_INFORMATION_FORMAT = dwarf-with-dsym       (Release)
DEBUG_INFORMATION_FORMAT = dwarf                  (Debug)

Release builds need dSYM for crash symbolication; signing happens after stripping symbols.

13.5 Configurations: Debug vs. Release

Almost every build setting can vary by configuration. The typical layout:

Setting Debug Release
CODE_SIGN_IDENTITY Apple Development Apple Distribution: Acme Inc.
PROVISIONING_PROFILE_SPECIFIER dev profile distribution profile
CODE_SIGN_ENTITLEMENTS MyApp.entitlements (with aps-environment = development) MyApp.Release.entitlements (with aps-environment = production)
SWIFT_OPTIMIZATION_LEVEL -Onone -O
DEBUG_INFORMATION_FORMAT dwarf dwarf-with-dsym

13.6 Reading Effective Settings

# All settings for a target/configuration
xcodebuild -project MyApp.xcodeproj -target MyApp \
           -configuration Release -showBuildSettings

# Filter to signing
xcodebuild -project MyApp.xcodeproj -target MyApp \
           -configuration Release -showBuildSettings \
           | grep -iE "code_sign|provisioning|team|entitle"

This is the single most useful command for debugging "Xcode is signing with the wrong cert". The output is the effective setting after all overrides — including from .xcconfig files, target settings, and project settings.

13.7 Using .xcconfig for Sanity

A .xcconfig file is a flat key-value file referenced by build configurations. They're fantastic for keeping signing config out of the (auto-generated, hard-to-merge) project.pbxproj:

// Signing.Release.xcconfig
DEVELOPMENT_TEAM = TEAMID1234
CODE_SIGN_STYLE = Manual
CODE_SIGN_IDENTITY = Apple Distribution
PROVISIONING_PROFILE_SPECIFIER = match AppStore com.acme.MyApp
CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.Release.entitlements
PRODUCT_BUNDLE_IDENTIFIER = com.acme.MyApp

Set this as the configuration file for your Release configuration in Xcode → Project → Info → Configurations. Now your signing config lives in version control as plain text, mergeable like any source file.


14. Automatic vs. Manual Signing

14.1 Automatic Signing (the default)

When you check "Automatically manage signing" in the target's Signing & Capabilities tab, Xcode does the following on every build:

  1. Reads your Team selection.
  2. Reads PRODUCT_BUNDLE_IDENTIFIER and the entitlements file.
  3. Calls Apple's developer API to:
  4. Downloads the profile to ~/Library/Developer/Xcode/UserData/Provisioning Profiles/.
  5. Sets PROVISIONING_PROFILE_SPECIFIER and CODE_SIGN_IDENTITY accordingly.
  6. Proceeds with the build.

This is fantastic for solo developers and quick prototyping. It's terrible for teams because:

14.2 Manual Signing

You explicitly set CODE_SIGN_IDENTITY and PROVISIONING_PROFILE_SPECIFIER. Xcode reads them, looks them up, fails loudly if anything's missing.

Manual signing is the right default for any team larger than one. Combined with Fastlane Match (covered later), it gives you reproducible, version-controlled signing.

14.3 The Hybrid: Manual for Distribution, Automatic for Dev

A common pragmatic setup:

This is configurable per-configuration in the Signing & Capabilities tab (the "Automatically manage signing" checkbox is per-configuration once you click the chevron next to Debug/Release).


15. Distribution Channels

You have four distribution paths off the iOS platform. Each has its own profile type and its own quirks.

15.1 Development Distribution (xcodebuild → connected device)

The base case. You hit Run in Xcode with a device plugged in.

15.2 Ad Hoc Distribution

Distribute outside the App Store / TestFlight to specific testers. Less common now that TestFlight is good, but useful when:

To export an Ad Hoc IPA from an archive:

xcodebuild -exportArchive \
  -archivePath build/MyApp.xcarchive \
  -exportPath build/AdHoc \
  -exportOptionsPlist ExportOptions-AdHoc.plist

ExportOptions-AdHoc.plist:

<dict>
  <key>method</key>
  <string>ad-hoc</string>
  <key>teamID</key>
  <string>TEAMID1234</string>
  <key>signingStyle</key>
  <string>manual</string>
  <key>provisioningProfiles</key>
  <dict>
    <key>com.acme.MyApp</key>
    <string>match AdHoc com.acme.MyApp</string>
  </dict>
</dict>

15.3 App Store Distribution

The default release path.

When you upload, Apple strips your signature and re-signs your binary with their own distribution certificate before delivering to users. This is why an App Store build's signature changes after upload — it's not the same bytes you uploaded. (A reason DRM-style fingerprinting based on signature hashes won't work post-distribution.)

The "FairPlay" wrapper is also added during App Store distribution — your executable section is encrypted, and only decrypts at runtime on the user's device, tied to their Apple ID. This is why you cannot statically analyze a third-party App Store binary with otool.

ExportOptions-AppStore.plist:

<dict>
  <key>method</key>
  <string>app-store-connect</string>
  <key>teamID</key>
  <string>TEAMID1234</string>
  <key>signingStyle</key>
  <string>manual</string>
  <key>uploadSymbols</key>
  <true/>
  <key>provisioningProfiles</key>
  <dict>
    <key>com.acme.MyApp</key>
    <string>match AppStore com.acme.MyApp</string>
    <key>com.acme.MyApp.Widget</key>
    <string>match AppStore com.acme.MyApp.Widget</string>
  </dict>
</dict>

Note: method was renamed from app-store to app-store-connect in Xcode 15+. Older .plists with app-store still work.

15.4 Enterprise (In-House) Distribution

Apple Developer Enterprise Program ($299/year, separate from the standard $99 program). Lets you distribute internally to employees without a device list and without App Store review.

Enterprise builds are only for distributing to your own employees. Apple will revoke your enterprise cert (and brick all your apps) if they catch you using it for the public. They're aggressive about this — there have been high-profile revocations of Facebook and Google's enterprise certs for misuse.

15.5 Comparison Table

Development Ad Hoc App Store Enterprise
Cert Apple Development Apple Distribution Apple Distribution Apple Distribution (Enterprise)
Profile iOS Dev Ad Hoc App Store In-House
Devices needed? Yes (≤100) Yes (≤100) No No
get-task-allow true false false false
Reaches public? No No Yes No (employees only)
Reviewed by Apple? No No Yes No
Apple re-signs at delivery? No No Yes No
Cost $99/yr $99/yr $99/yr $299/yr

16. TestFlight and App Store Connect Internals

TestFlight is technically a distribution mechanism, not a profile type. Builds uploaded to App Store Connect can be released via:

All of these use the App Store profile type. The decision of which channel a build goes to happens in App Store Connect, not in your build settings.

16.1 What Apple Validates on Upload

When you upload an .ipa to App Store Connect (via xcrun altool legacy, xcrun notarytool for macOS, or the modern xcodebuild -exportArchive + xcrun altool flow), Apple:

  1. Validates your signature chains to a known Apple Distribution cert.
  2. Checks get-task-allow = false.
  3. Checks aps-environment = production (if push is enabled).
  4. Verifies the binary's LC_VERSION_MIN_IPHONEOS (deployment target) is ≥ Apple's current minimum (currently iOS 15 for new submissions, 18 for some advanced features).
  5. Validates the bundle ID matches what's registered in App Store Connect.
  6. Validates the Info.plist (required keys, valid version strings).
  7. Validates that you haven't used private APIs (basic symbol checks).
  8. Stores the .ipa and triggers TestFlight processing.

After processing, the build appears in App Store Connect → TestFlight tab. Common failure modes:

The error is always emailed to the team agent — a step many people miss.

16.2 Symbols and dSYMs

Crash reports come back symbolicated only if you uploaded the dSYM. Either:

For Crashlytics or other third-party crash reporters, you'll generally upload the dSYM to them via a script run phase using their CLI tool.


17. CI/CD with Fastlane Match

Once you've internalized everything above, Fastlane Match stops feeling like magic and starts feeling like a sensible automation of what you already understand.

17.1 What Match Does

Match's core idea:

  1. Generate a single shared certificate (Development or Distribution) and private key.
  2. Encrypt the cert + private key + profiles using a passphrase you control.
  3. Push the encrypted blob to a private Git repo (or S3, Google Cloud Storage, etc.).
  4. Every developer and CI machine pulls from the repo, decrypts using the passphrase, and installs the cert + profiles into their keychain.

The result: every machine in your team uses the same signing identity. No more "works on my machine, fails on CI" because of mismatched profiles.

17.2 Setting Up Match

# Install
brew install fastlane

# In your project root
fastlane match init

This walks you through choosing storage (Git is most common). Creates Matchfile:

git_url("git@github.com:acme/certificates.git")
storage_mode("git")
type("development")  # default; can be overridden per-lane
app_identifier(["com.acme.MyApp", "com.acme.MyApp.Widget"])
username("ci@acme.com")
team_id("TEAMID1234")

Then run, separately for each profile type you need:

# Generates dev cert + dev profiles for all bundle IDs
fastlane match development

# Distribution cert + App Store profiles
fastlane match appstore

# Distribution cert (same as appstore) + Ad Hoc profiles
fastlane match adhoc

The first run prompts for a passphrase. Save that passphrase in 1Password / your secrets manager. Without it, the encrypted Git contents are useless.

17.3 What Match Stores in Git

Look at the certificates Git repo after running Match:

certificates/
├── README.md
├── certs/
│   ├── development/
│   │   ├── ABCD1234EF.cer        # public cert
│   │   └── ABCD1234EF.p12        # encrypted cert + private key
│   └── distribution/
│       ├── EFGH5678IJ.cer
│       └── EFGH5678IJ.p12
└── profiles/
    ├── development/
    │   ├── Development_com.acme.MyApp.mobileprovision
    │   └── Development_com.acme.MyApp.Widget.mobileprovision
    └── appstore/
        ├── AppStore_com.acme.MyApp.mobileprovision
        └── AppStore_com.acme.MyApp.Widget.mobileprovision

The .p12 is encrypted with your match passphrase. The .cer is just the public certificate (already public, no need to encrypt). The .mobileprovision files are encrypted too.

17.4 Wiring Match into Xcode

After running fastlane match appstore, your Xcode project should be set to manual signing with:

PROVISIONING_PROFILE_SPECIFIER = match AppStore com.acme.MyApp
CODE_SIGN_IDENTITY = Apple Distribution
DEVELOPMENT_TEAM = TEAMID1234

The format match AppStore com.acme.MyApp is just the profile name match generates. You can write it explicitly in .xcconfig and forget about it.

17.5 A Realistic Fastlane File

fastlane/Fastfile:

default_platform(:ios)

platform :ios do

  desc "Run unit tests"
  lane :test do
    run_tests(
      scheme: "MyApp",
      device: "iPhone 15"
    )
  end

  desc "Sync development signing"
  lane :sync_dev_signing do
    match(type: "development", readonly: is_ci)
  end

  desc "Sync release signing"
  lane :sync_release_signing do
    match(type: "appstore", readonly: is_ci)
  end

  desc "Build and upload to TestFlight"
  lane :beta do
    sync_release_signing
    increment_build_number(xcodeproj: "MyApp.xcodeproj")
    build_app(
      scheme: "MyApp",
      configuration: "Release",
      export_method: "app-store",
      export_options: {
        provisioningProfiles: {
          "com.acme.MyApp"        => "match AppStore com.acme.MyApp",
          "com.acme.MyApp.Widget" => "match AppStore com.acme.MyApp.Widget"
        }
      }
    )
    upload_to_testflight(
      skip_waiting_for_build_processing: true,
      api_key_path: "fastlane/AppStoreConnectAPI.json"
    )
  end

end

Key design choices:

17.6 The App Store Connect API Key

Apple's modern auth for CI:

  1. App Store Connect → Users and Access → Keys → App Store Connect API.
  2. Generate a key with at least App Manager role.
  3. Download the .p8 file (only once — re-download is impossible).
  4. Note the Key ID and Issuer ID.

AppStoreConnectAPI.json:

{
  "key_id": "ABC123DEFG",
  "issuer_id": "12345678-1234-1234-1234-123456789abc",
  "key": "-----BEGIN PRIVATE KEY-----\nMIGTA...\n-----END PRIVATE KEY-----",
  "in_house": false
}

Store this whole JSON as a CI secret (GitHub Actions: encrypted secret; GitLab: protected variable).

17.7 GitHub Actions Example

.github/workflows/beta.yml:

name: Beta Build

on:
  push:
    branches: [release/*]

jobs:
  build:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4

      - name: Select Xcode
        run: sudo xcode-select -s /Applications/Xcode_16.0.app

      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2'
          bundler-cache: true

      - name: Set up SSH for match repo
        uses: webfactory/ssh-agent@v0.9.0
        with:
          ssh-private-key: ${{ secrets.MATCH_SSH_KEY }}

      - name: Write App Store Connect API key
        run: |
          echo '${{ secrets.ASC_API_KEY_JSON }}' \
            > fastlane/AppStoreConnectAPI.json

      - name: Beta lane
        env:
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          MATCH_KEYCHAIN_NAME: ci-keychain
          MATCH_KEYCHAIN_PASSWORD: ${{ secrets.CI_KEYCHAIN_PASSWORD }}
        run: bundle exec fastlane beta

Two things worth understanding:


18. Debugging Common Signing Failures

Real-world errors and what they mean.

18.1 "No profiles for 'com.acme.MyApp' were found"

Cause: there's no profile in ~/Library/Developer/Xcode/UserData/Provisioning Profiles/ whose application-identifier entitlement matches TEAMID.com.acme.MyApp.

Fixes (in order of likelihood):

  1. Wrong team selected. Check Signing & Capabilities → Team.
  2. Profile not downloaded. Run fastlane match development --readonly, or hit "Download Manual Profiles" in Xcode → Settings → Accounts → Manage Certificates.
  3. Profile expired. Look at ExpirationDate of the file. Regenerate.
  4. Bundle ID typo. Check PRODUCT_BUNDLE_IDENTIFIER matches the App ID exactly (case-sensitive).

18.2 "Provisioning profile X doesn't include signing certificate Y"

Cause: the cert in your keychain isn't in the profile's DeveloperCertificates.

Fixes:

  1. Profile is stale. Regenerate after the cert was rotated.
  2. You have multiple certs. security find-identity -v -p codesigning — pick one and pin it via CODE_SIGN_IDENTITY.
  3. Match passphrase mismatch. A subtle one: if two team members use different match passphrases against the same repo, one of them is generating new certs that the other's profiles don't include. Resolve by aligning passphrases and re-running match nuke development && match development.

18.3 "Provisioning profile doesn't include the X entitlement"

Cause: your .entitlements file requests a capability the profile doesn't grant.

Fixes:

  1. Capability not enabled on App ID. Developer portal → Identifiers → your App ID → enable the capability → save → regenerate profile.
  2. Wrong profile environment. Push notifications: dev profile grants aps-environment = development; release profile grants production. If you build Release with a dev profile, this fires.
  3. Hand-edited entitlements. You added a key Xcode doesn't know about. Either remove it or enable the corresponding capability.

18.4 "A signed resource has been added, modified, or deleted"

Cause: something altered the bundle after signing. Common culprits:

Fix: examine your build phases. Anything that touches the bundle must run before the Sign phase. Drag phases into correct order in the target's Build Phases tab.

18.5 "Embedded binary is not signed with the same certificate as the parent app"

Cause: a framework or extension is signed with cert A, the main app with cert B.

Fix:

  1. Make sure all targets in the same project use the same CODE_SIGN_IDENTITY and DEVELOPMENT_TEAM.
  2. If using third-party frameworks vendored as .framework bundles, ensure "Code Sign On Copy" is checked in Embed Frameworks. This re-signs them with your identity during embed.

18.6 "App installation failed: Could not install"

Often a device-side complaint, less informative. Try:

# Xcode → Window → Devices and Simulators → device → "View Device Logs"
# Or:
log stream --device --predicate 'subsystem == "com.apple.MobileInstallation"'

The MobileInstallation logs spell out exactly what failed: profile not on device, UDID not authorized, entitlement mismatch, etc.

18.7 "Failed to register bundle identifier"

Cause: that bundle ID is taken (in another team) or already exists in your team but you don't have permission.

Fix: pick a different bundle ID, or contact your team admin.

18.8 General Debugging Workflow

When in doubt, run the four commands:

# 1. What identities are on this machine?
security find-identity -v -p codesigning

# 2. What profiles are cached?
ls -la ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/

# 3. What's the effective signing setup?
xcodebuild -project MyApp.xcodeproj -target MyApp \
           -configuration Release -showBuildSettings \
           | grep -iE "code_sign|provisioning|team"

# 4. Inspect the active profile
security cms -D -i ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/<UUID>.mobileprovision

90% of signing problems become obvious from those four outputs.


19. Advanced Topics

19.1 App Extensions

Every extension (Today widget, Share extension, Notification Service Extension, Notification Content Extension, Intents extension, WidgetKit extension, Network Extension, etc.) is a separate target with:

Build settings will look like:

# Host app
PRODUCT_BUNDLE_IDENTIFIER = com.acme.MyApp
PROVISIONING_PROFILE_SPECIFIER = match AppStore com.acme.MyApp

# Widget extension
PRODUCT_BUNDLE_IDENTIFIER = com.acme.MyApp.Widget
PROVISIONING_PROFILE_SPECIFIER = match AppStore com.acme.MyApp.Widget

When archiving, Xcode signs the extension first, embeds it into the host app's PlugIns/ directory, then signs the host app — the host's signature seals the (already-signed) extension. The chain validates because the extension's own signature is also valid.

In match, you list every bundle ID:

app_identifier([
  "com.acme.MyApp",
  "com.acme.MyApp.Widget",
  "com.acme.MyApp.NotificationService",
  "com.acme.MyApp.ShareExtension"
])

Match generates a separate profile for each.

19.2 App Groups

App Groups let your host app and extensions share UserDefaults, files (via FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:)), and Keychain items.

Setup:

  1. Developer portal → Identifiers → App Groups → + → register group.com.acme.shared.

  2. Enable App Groups capability on each App ID, select the group.

  3. In Xcode, on each target's Signing & Capabilities tab → + Capability → App Groups → check the group.

  4. Xcode adds to each .entitlements:

    <key>com.apple.security.application-groups</key>
    <array>
      <string>group.com.acme.shared</string>
    </array>
    
  5. Regenerate profiles (Match: fastlane match nuke development && fastlane match development).

In code:

// Shared UserDefaults
let shared = UserDefaults(suiteName: "group.com.acme.shared")
shared?.set("hello", forKey: "greeting")

// Shared file container
let url = FileManager.default
  .containerURL(forSecurityApplicationGroupIdentifier: "group.com.acme.shared")!
  .appendingPathComponent("data.json")

The two targets — host app and extension — must both have the App Group entitlement, and both must have a profile granting it. If only one does, the other's containerURL(...) returns nil.

19.3 Push Notifications

Two pieces:

App side (entitlement):

<key>aps-environment</key>
<string>development</string>   <!-- or "production" for App Store / TestFlight -->

Server side (auth to APNs): two options.

Generate an APNs Authentication Key at App Store Connect → Users and Access → Keys → APNs. This gives a single .p8 that works for any of your apps (per team). Your server signs short-lived JWTs with this key to authenticate to APNs.

# Pseudo-code: server-side JWT for APNs
jwt = encode_jwt(
    headers={"alg": "ES256", "kid": APNS_KEY_ID},
    payload={"iss": TEAM_ID, "iat": int(time.time())},
    key=APNS_KEY_PRIVATE_BYTES
)
# Send notification with this JWT in Authorization: bearer <jwt>

Certificate-based auth (legacy)

You generate an Apple Push Services certificate per app on the developer portal. Export it as .p12 and load on your server. Each app needs its own cert; certs expire annually.

Token-based is the modern path; cert-based is still supported but you should not be choosing it for new projects.

In your .entitlements:

<key>com.apple.developer.associated-domains</key>
<array>
  <string>applinks:acme.com</string>
  <string>applinks:www.acme.com</string>
  <string>webcredentials:acme.com</string>
</array>

Server side: host an apple-app-site-association JSON file at https://acme.com/.well-known/apple-app-site-association (no .json extension):

{
  "applinks": {
    "details": [{
      "appIDs": ["TEAMID1234.com.acme.MyApp"],
      "components": [
        { "/": "/products/*" },
        { "/": "/account/*" }
      ]
    }]
  },
  "webcredentials": {
    "apps": ["TEAMID1234.com.acme.MyApp"]
  }
}

Apple's CDN (in iOS 14+) caches this file, fetched at app install time. Bugs in this JSON are silent — the entitlement appears correct but links don't open in your app. Test by tapping the link from Notes, or use Settings → Developer → Universal Links Diagnostics on a developer-mode device.

19.5 Hardened Runtime (mostly macOS, but iOS-relevant)

iOS apps run with a hardened runtime by default — you don't toggle it. But certain entitlements (debugger attach, JIT, dyld variables) require explicit opt-in:

<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>

These are rare on iOS — you'll see them most often when shipping JS-engine apps (React Native dev menu, advanced WebKit usage) or DRM-protected media. If you're using them, you should know exactly why.

19.6 On-Device Re-Signing for IPA Distribution

If you have a built .ipa and need to ship it to a different team (white-label scenarios, IT redistribution):

# 1. Unzip
unzip -q MyApp.ipa -d MyApp_unzipped
cd MyApp_unzipped

# 2. Replace embedded profile
cp /path/to/NewTeam_AdHoc.mobileprovision Payload/MyApp.app/embedded.mobileprovision

# 3. Extract the entitlements from the new profile to use during re-sign
security cms -D -i Payload/MyApp.app/embedded.mobileprovision \
  > /tmp/profile.plist
/usr/libexec/PlistBuddy -x -c "Print :Entitlements" /tmp/profile.plist \
  > /tmp/entitlements.plist

# 4. Re-sign frameworks (bottom-up!)
for fw in Payload/MyApp.app/Frameworks/*.framework \
          Payload/MyApp.app/Frameworks/*.dylib; do
  codesign --force --sign "Apple Distribution: NewTeam (NEWTEAMID12)" \
           "$fw"
done

# 5. Re-sign extensions
for ext in Payload/MyApp.app/PlugIns/*.appex; do
  # Extract that extension's entitlements similarly, sign with them
  codesign --force --sign "Apple Distribution: NewTeam (NEWTEAMID12)" \
           --entitlements /tmp/ext-entitlements.plist \
           "$ext"
done

# 6. Re-sign main app
codesign --force --sign "Apple Distribution: NewTeam (NEWTEAMID12)" \
         --entitlements /tmp/entitlements.plist \
         Payload/MyApp.app

# 7. Verify
codesign -vvv --deep --strict Payload/MyApp.app

# 8. Repackage
zip -qr MyApp_resigned.ipa Payload

There's also a popular wrapper called iresign (and various open-source tools like iOS-Resign-Tool) that automates this dance.

19.7 Notarization (macOS Only)

Mentioned for completeness: macOS apps distributed outside the Mac App Store must be notarized — submitted to Apple's notary service, which scans for malware and returns a "ticket" that gets stapled to the app. iOS has no equivalent because all iOS apps are gated through App Store / TestFlight (or Enterprise / Ad Hoc) anyway.

19.8 visionOS, watchOS, tvOS — Same Rules, Different Profile Types

The signing model is identical across Apple's platforms:

The unified "Apple Development" / "Apple Distribution" certs (from section 5) work for all of them. Only the profile types are platform-specific.


20. Best Practices and Mental Hygiene

A few rules of thumb earned from blood-on-the-floor lessons:

20.1 Use Manual Signing for Anything Shared

The moment more than one person — or one machine — builds your app, switch to manual signing and Match (or an equivalent). Automatic signing is fine for solo prototyping; it's an active source of pain on teams.

20.2 Put Signing Config in .xcconfig

The project.pbxproj is auto-generated and merge-conflicts hell. Move signing-related build settings into .xcconfig files in version control. Plain text, mergeable, reviewable.

20.3 Don't Check .p12 Files into Source Control Unencrypted

Even private repos. A leaked private signing key means anyone can ship malware as your team. Match encrypts everything — use it.

20.4 Use App Store Connect API Keys, Not Apple ID + Password

2FA-protected Apple IDs break CI flows. Get an API key, store it as a CI secret, never look back.

20.5 Watch Your Profile Expiration

Add a calendar reminder for ~3 weeks before your profile expires. A stale profile in production is fine if your app's already on user devices via App Store; it bites in TestFlight (where users get "this beta has expired"). The trick: ship a fresh build before the expiration date.

20.6 Test on a Fresh Mac Once a Year

Spin up a new Mac (cloud Mac, fresh CI runner, restored backup) and try to build your app from scratch using only the docs in your repo. If it doesn't work, your docs are wrong. This is the litmus test for whether new hires can actually start contributing on day one.

20.7 Read Every Line of Build Logs the First Time Something Fails

Don't skim. The exact error text matters. The five-line context matters. Searching the exact error string on Apple Developer Forums almost always finds your problem. Most signing issues have a known cause; the trick is matching the symptom precisely.

20.8 Treat Your Signing Repo Like a Disaster Recovery Asset

If your match Git repo is destroyed or its credentials are lost, your team can no longer ship. Mirror it. Back up the .p12s elsewhere. Document the passphrase in a way two people can recover it. This is your business-continuity asset.

20.9 When in Doubt, nuke

fastlane match nuke development and fastlane match nuke distribution revoke and delete every cert and profile in their category. Sometimes you've drifted so far that the cleanest path forward is a clean slate. The cost is ~5 minutes of regeneration. Don't fear it.

20.10 Version Control Your ExportOptions.plist

The export options are part of how you distribute, and reviewing changes to them in PR is valuable. Keep them in fastlane/, not in scratch directories.


21. Glossary

A reference to refer back to.


Closing Thoughts

Code signing on iOS feels like alchemy until you understand the four agreements from section 2:

  1. The binary is signed by a key the device trusts.
  2. The binary's bundle ID matches a known App ID.
  3. The device is permitted (in the profile, or via App Store).
  4. The entitlements are a subset of what the App ID grants.

Every error message is one of those four agreements failing. Once you can read a .mobileprovision by hand, every signing failure becomes a structured debugging exercise rather than a roll of the dice.

Save this guide. Refer back to it the next time someone on your team asks why their TestFlight build won't install. You'll be the one who actually knows.

Now go ship.